Home v2: top bar with Join/Host tabs, content-first layout
Top bar merges window chrome, profile, tabs and page icons. Join: one compact row plus recent-server tiles with live /health status and a needs-update badge. Host: reusable MediaPicker (Jellyfin placeholder, Link, File) filling the width, server settings in a narrow column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81cac1214d
commit
0669ec0fc2
12 files changed
+1689
-557
No files matched your search
@@ -23,7 +23,7 @@ Download the latest `watch-together-<version>-linux-x64.tar.gz` from the
|
|||||||
Releases page of this repo, extract it, run `watch_together`.
|
Releases page of this repo, extract it, run `watch_together`.
|
||||||
|
|
||||||
Update from the app: when a newer version exists, an update indicator
|
Update from the app: when a newer version exists, an update indicator
|
||||||
appears on Home and opens the update card. "Update and restart" downloads,
|
appears in Home's top bar and opens the update card. "Update and restart" downloads,
|
||||||
verifies, applies in place and restarts; the install folder must be writable
|
verifies, applies in place and restarts; the install folder must be writable
|
||||||
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
||||||
`watch_together --upgrade` or `update.sh` (shipped in the tarball). A
|
`watch_together --upgrade` or `update.sh` (shipped in the tarball). A
|
||||||
@@ -33,9 +33,11 @@ telling you to update.
|
|||||||
|
|
||||||
## Host from the app
|
## Host from the app
|
||||||
|
|
||||||
Home, Host card. Pick a port, an optional password (blank = anyone can
|
Home, Host tab. Pick what to watch (optional): Link (paste an http(s) URL;
|
||||||
join), and optionally a video: a link or a file path. Your name and avatar
|
Ctrl+V outside a field pastes it in) or File (path or Browse). Jellyfin is
|
||||||
come from your profile. The app starts a
|
coming. In the column beside it set a port and an optional password (blank =
|
||||||
|
anyone can join), then Host. Your name and avatar come from your profile
|
||||||
|
(the chip at the top left). The app starts a
|
||||||
server inside itself and joins it; you are the host and nobody can kick you.
|
server inside itself and joins it; you are the host and nobody can kick you.
|
||||||
Leaving or closing the app stops the server. Friends need to reach your machine: open
|
Leaving or closing the app stops the server. Friends need to reach your machine: open
|
||||||
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
|
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
|
||||||
@@ -70,9 +72,12 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
|||||||
|
|
||||||
## Join
|
## Join
|
||||||
|
|
||||||
Home, Join card. Enter the host's address, port, and the password if there
|
Home, Join tab. Enter the host's address, port, and the password if there
|
||||||
is one. Your name and avatar come from your profile (Home chip or Settings).
|
is one, then Enter or Join. Your name and avatar come from your profile (top
|
||||||
Servers you got into appear as up to 6 Recent chips on Home: one click rejoins.
|
bar chip or Settings). Servers you got into appear as up to 6 Recent server
|
||||||
|
tiles with live status (online and how many are watching, or offline, plus
|
||||||
|
"needs update" when the server runs another protocol): click one to rejoin,
|
||||||
|
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import 'connect_flow.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
/// The submit button for one [ConnectMode], swapped for a progress bar and
|
||||||
|
/// Cancel while that attempt runs. Disabled while the other mode is busy.
|
||||||
|
class ConnectButton extends StatelessWidget {
|
||||||
|
final ConnectFlow flow;
|
||||||
|
final ConnectMode mode;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
final bool expand;
|
||||||
|
const ConnectButton({
|
||||||
|
super.key,
|
||||||
|
required this.flow,
|
||||||
|
required this.mode,
|
||||||
|
required this.label,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.expand = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final busyHere = flow.busy == mode;
|
||||||
|
if (busyHere) {
|
||||||
|
return SizedBox(
|
||||||
|
width: expand ? null : 220,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final button = FilledButton(
|
||||||
|
onPressed: flow.busy != null ? null : onSubmit,
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
return expand ? SizedBox(width: double.infinity, child: button) : button;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last error or notice for [mode], or nothing.
|
||||||
|
class ConnectFeedback extends StatelessWidget {
|
||||||
|
final ConnectFlow flow;
|
||||||
|
final ConnectMode mode;
|
||||||
|
const ConnectFeedback({super.key, required this.flow, required this.mode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final error = flow.errors[mode];
|
||||||
|
final notice = flow.notices[mode];
|
||||||
|
if (error != null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: ErrorBox(title: error.title, message: error.message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (notice != null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(notice,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
/// Fetches a URL body; injectable so tests fake the server's `/health`.
|
||||||
|
typedef HealthGetter = Future<String> Function(Uri uri);
|
||||||
|
|
||||||
|
const healthPollEvery = Duration(seconds: 10);
|
||||||
|
const _timeout = Duration(seconds: 4);
|
||||||
|
|
||||||
|
/// What a recent-server tile shows: unknown until the first probe answers.
|
||||||
|
class ServerHealth {
|
||||||
|
final bool online;
|
||||||
|
final String name;
|
||||||
|
final int? proto;
|
||||||
|
final int viewers;
|
||||||
|
const ServerHealth.offline()
|
||||||
|
: online = false,
|
||||||
|
name = '',
|
||||||
|
proto = null,
|
||||||
|
viewers = 0;
|
||||||
|
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
|
||||||
|
: online = true;
|
||||||
|
|
||||||
|
/// True when the server speaks another protocol than this build.
|
||||||
|
bool get needsUpdate =>
|
||||||
|
online && proto != null && proto != RelayConstants.protoVersion;
|
||||||
|
|
||||||
|
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
|
||||||
|
static ServerHealth parse(String body) {
|
||||||
|
try {
|
||||||
|
final j = jsonDecode(body);
|
||||||
|
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
||||||
|
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
|
||||||
|
return ServerHealth.online(
|
||||||
|
name: name is String ? name.trim() : '',
|
||||||
|
proto: proto is num ? proto.toInt() : null,
|
||||||
|
viewers: viewers is num ? viewers.toInt() : 0,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const ServerHealth.offline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri healthUri(String host, String port) {
|
||||||
|
final h = host.trim();
|
||||||
|
// Bare IPv6 literals need brackets in a URL.
|
||||||
|
final authority = h.contains(':') && !h.startsWith('[') ? '[$h]' : h;
|
||||||
|
return Uri.parse('http://$authority:${port.trim()}/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ServerHealth> probeHealth(String host, String port, HealthGetter get) async {
|
||||||
|
try {
|
||||||
|
return ServerHealth.parse(await get(healthUri(host, port)).timeout(_timeout));
|
||||||
|
} catch (_) {
|
||||||
|
return const ServerHealth.offline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain dart:io GET; non-200 throws.
|
||||||
|
Future<String> defaultHealthGet(Uri uri) async {
|
||||||
|
final client = HttpClient()..connectionTimeout = _timeout;
|
||||||
|
try {
|
||||||
|
final req = await client.getUrl(uri);
|
||||||
|
final res = await req.close().timeout(_timeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
await res.drain<void>();
|
||||||
|
throw HttpException('status ${res.statusCode}', uri: uri);
|
||||||
|
}
|
||||||
|
return await res.transform(utf8.decoder).join().timeout(_timeout);
|
||||||
|
} finally {
|
||||||
|
client.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
|
|
||||||
import '../../state/session_state.dart';
|
|
||||||
import '../../update/update_controller.dart';
|
|
||||||
import 'connect_flow.dart';
|
|
||||||
import 'profile.dart';
|
|
||||||
import 'ui.dart';
|
|
||||||
import 'update_indicator.dart';
|
|
||||||
|
|
||||||
/// Below this width the Join/Host cards stack.
|
|
||||||
const _stackBelow = 700.0;
|
|
||||||
|
|
||||||
const _videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
|
||||||
final UpdateController? updates;
|
|
||||||
const HomePage({super.key, this.updates});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<HomePage> createState() => _HomePageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
|
||||||
final _flow = ConnectFlow();
|
|
||||||
late final _f = SessionState.instance.lastForm;
|
|
||||||
late final _joinHost = TextEditingController(text: _f.host);
|
|
||||||
late final _joinPort = TextEditingController(text: _f.port);
|
|
||||||
late final _joinSecret = TextEditingController(text: _f.secret);
|
|
||||||
late final _hostPort = TextEditingController(text: _f.hostPort);
|
|
||||||
late final _hostSecret = TextEditingController(text: _f.hostSecret);
|
|
||||||
late final _media =
|
|
||||||
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
|
|
||||||
late final void Function() _stopKickWatch;
|
|
||||||
late final void Function() _stopCrashWatch;
|
|
||||||
bool _dialogOpen = false;
|
|
||||||
String? _lastKickShown;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_flow.onKicked = _showKickedDialog;
|
|
||||||
// Back from a session that ended in a kick: say so.
|
|
||||||
_stopKickWatch = effect(() {
|
|
||||||
final kick = SessionState.instance.coordinator.kickReason.value;
|
|
||||||
if (kick == null || kick.isEmpty) {
|
|
||||||
_lastKickShown = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
|
||||||
});
|
|
||||||
// Back from a session whose hosted server crashed: say so on the Host card.
|
|
||||||
_stopCrashWatch = effect(() {
|
|
||||||
final crash = SessionState.instance.serverCrash.value;
|
|
||||||
if (crash == null) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
SessionState.instance.serverCrash.value = null;
|
|
||||||
if (mounted) _flow.showError(ConnectMode.host, CardError('Server crashed', crash));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_stopKickWatch();
|
|
||||||
_stopCrashWatch();
|
|
||||||
_flow.dispose();
|
|
||||||
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
|
|
||||||
c.dispose();
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _join() => _flow.submit(ConnectMode.join, (f) {
|
|
||||||
f
|
|
||||||
..host = _joinHost.text
|
|
||||||
..port = _joinPort.text.trim()
|
|
||||||
..secret = _joinSecret.text;
|
|
||||||
});
|
|
||||||
|
|
||||||
void _host() => _flow.submit(ConnectMode.host, (f) {
|
|
||||||
f
|
|
||||||
..hostPort = _hostPort.text.trim()
|
|
||||||
..hostSecret = _hostSecret.text
|
|
||||||
..mediaPath = _media.text.trim()
|
|
||||||
..linkUrl = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
void _useRecent(RecentServer r) {
|
|
||||||
if (r.hosted) {
|
|
||||||
_hostPort.text = r.port;
|
|
||||||
_hostSecret.text = r.secret;
|
|
||||||
setState(() {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_joinHost.text = r.host;
|
|
||||||
_joinPort.text = r.port;
|
|
||||||
_joinSecret.text = r.secret;
|
|
||||||
_join();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _browseMedia() async {
|
|
||||||
try {
|
|
||||||
final files = await FilePicker.pickFiles(
|
|
||||||
dialogTitle: 'Pick media file',
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: _videoExtensions,
|
|
||||||
);
|
|
||||||
final path = files.isEmpty ? null : files.first.path;
|
|
||||||
if (path != null && mounted) setState(() => _media.text = path);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_flow.notices[ConnectMode.host] =
|
|
||||||
'File dialog unavailable (${e.runtimeType}) — paste the path instead';
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showKickedDialog(String reason) async {
|
|
||||||
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
|
|
||||||
_lastKickShown = reason;
|
|
||||||
_dialogOpen = true;
|
|
||||||
try {
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('You were removed'),
|
|
||||||
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
|
|
||||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
_dialogOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ListenableBuilder(
|
|
||||||
listenable: _flow,
|
|
||||||
builder: (context, _) => PageScroll(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const ProfileChip(),
|
|
||||||
const Spacer(),
|
|
||||||
UpdateIndicator(controller: widget.updates),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
LayoutBuilder(builder: (context, box) {
|
|
||||||
final stacked = box.maxWidth < _stackBelow;
|
|
||||||
final join = _joinCard(context, fill: !stacked);
|
|
||||||
final host = _hostCard(context, fill: !stacked);
|
|
||||||
if (stacked) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [join, const SizedBox(height: kGap), host],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return IntrinsicHeight(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(child: join),
|
|
||||||
const SizedBox(width: kGap),
|
|
||||||
Expanded(child: host),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
_recents(context),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _joinCard(BuildContext context, {required bool fill}) {
|
|
||||||
return _ConnectCard(
|
|
||||||
key: const ValueKey('join-card'),
|
|
||||||
icon: Icons.login,
|
|
||||||
title: 'Join',
|
|
||||||
subtitle: 'Connect to a friend’s server.',
|
|
||||||
mode: ConnectMode.join,
|
|
||||||
flow: _flow,
|
|
||||||
action: 'Join',
|
|
||||||
onSubmit: _join,
|
|
||||||
fill: fill,
|
|
||||||
fields: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: TextField(
|
|
||||||
controller: _joinHost,
|
|
||||||
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
|
|
||||||
onChanged: (_) => _flow.clearError(ConnectMode.join),
|
|
||||||
onSubmitted: (_) => _join(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _joinPort,
|
|
||||||
decoration: fieldDecoration('Port'),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onSubmitted: (_) => _join(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: _joinSecret,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: fieldDecoration('Password (optional)'),
|
|
||||||
onSubmitted: (_) => _join(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _hostCard(BuildContext context, {required bool fill}) {
|
|
||||||
return _ConnectCard(
|
|
||||||
key: const ValueKey('host-card'),
|
|
||||||
icon: Icons.cast,
|
|
||||||
title: 'Host',
|
|
||||||
subtitle: 'Run the server here. Your friends need this port open.',
|
|
||||||
mode: ConnectMode.host,
|
|
||||||
flow: _flow,
|
|
||||||
action: 'Host',
|
|
||||||
onSubmit: _host,
|
|
||||||
fill: fill,
|
|
||||||
fields: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 110,
|
|
||||||
child: TextField(
|
|
||||||
controller: _hostPort,
|
|
||||||
decoration: fieldDecoration('Port'),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onSubmitted: (_) => _host(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ListenableBuilder(
|
|
||||||
listenable: _hostSecret,
|
|
||||||
builder: (context, _) => TextField(
|
|
||||||
controller: _hostSecret,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: fieldDecoration('Password (optional)').copyWith(
|
|
||||||
helperText: _hostSecret.text.isEmpty
|
|
||||||
? 'No password — anyone can join'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _host(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TextField(
|
|
||||||
controller: _media,
|
|
||||||
decoration: fieldDecoration(
|
|
||||||
'Media',
|
|
||||||
hint: 'File path or http(s) link — optional',
|
|
||||||
suffix: Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: TextButton(onPressed: _browseMedia, child: const Text('Browse')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _host(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _recents(BuildContext context) {
|
|
||||||
return SignalBuilder(builder: (context) {
|
|
||||||
final list = SessionState.instance.recents.value;
|
|
||||||
if (list.isEmpty) return const SizedBox.shrink();
|
|
||||||
final busy = _flow.busy != null;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Recent',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.labelLarge
|
|
||||||
?.copyWith(color: Colors.grey)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final r in list)
|
|
||||||
InputChip(
|
|
||||||
key: ValueKey('recent-${r.key}'),
|
|
||||||
avatar: Icon(r.hosted ? Icons.cast : Icons.dns_outlined, size: 18),
|
|
||||||
label: Text(r.hosted ? 'Hosted on :${r.port}' : '${r.host}:${r.port}'),
|
|
||||||
tooltip: r.hosted ? 'Fill the Host card' : 'Join ${r.host}:${r.port}',
|
|
||||||
isEnabled: !busy,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
onPressed: () => _useRecent(r),
|
|
||||||
deleteButtonTooltipMessage: 'Forget',
|
|
||||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectCard extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final ConnectMode mode;
|
|
||||||
final ConnectFlow flow;
|
|
||||||
final String action;
|
|
||||||
final VoidCallback onSubmit;
|
|
||||||
final List<Widget> fields;
|
|
||||||
// Side by side: push the button to the bottom so both cards line up.
|
|
||||||
final bool fill;
|
|
||||||
const _ConnectCard({
|
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.mode,
|
|
||||||
required this.flow,
|
|
||||||
required this.action,
|
|
||||||
required this.onSubmit,
|
|
||||||
required this.fields,
|
|
||||||
required this.fill,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final scheme = theme.colorScheme;
|
|
||||||
final busyHere = flow.busy == mode;
|
|
||||||
final busyElsewhere = flow.busy != null && !busyHere;
|
|
||||||
final error = flow.errors[mode];
|
|
||||||
final notice = flow.notices[mode];
|
|
||||||
return Panel(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 22, color: scheme.primary),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(title, style: theme.textTheme.titleLarge),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(subtitle, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
|
||||||
const SizedBox(height: kGap),
|
|
||||||
AbsorbPointer(
|
|
||||||
absorbing: busyHere,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
spacing: 12,
|
|
||||||
children: fields,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (error != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_ErrorBox(error: error),
|
|
||||||
] else if (notice != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(notice, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
|
||||||
],
|
|
||||||
if (fill) const Spacer(),
|
|
||||||
const SizedBox(height: kGap),
|
|
||||||
if (busyHere) ...[
|
|
||||||
const LinearProgressIndicator(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
|
||||||
style: theme.textTheme.bodySmall),
|
|
||||||
),
|
|
||||||
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: busyElsewhere ? null : onSubmit,
|
|
||||||
child: Text(action),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ErrorBox extends StatelessWidget {
|
|
||||||
final CardError error;
|
|
||||||
const _ErrorBox({required this.error});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: scheme.errorContainer.withValues(alpha: 0.35),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error_outline, size: 18, color: scheme.error),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: SelectableText.rich(
|
|
||||||
TextSpan(children: [
|
|
||||||
TextSpan(
|
|
||||||
text: '${error.title}\n',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
TextSpan(text: error.message),
|
|
||||||
]),
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,97 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import '../../widgets/window_chrome_bar.dart';
|
|
||||||
import 'about_page.dart';
|
import 'about_page.dart';
|
||||||
import 'home_page.dart';
|
import 'connect_flow.dart';
|
||||||
|
import 'health_probe.dart';
|
||||||
|
import 'host_tab.dart';
|
||||||
import 'integrations_page.dart';
|
import 'integrations_page.dart';
|
||||||
|
import 'join_tab.dart';
|
||||||
import 'profile.dart';
|
import 'profile.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'ui.dart';
|
import 'top_bar.dart';
|
||||||
import 'update_indicator.dart';
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
/// Everything outside a session: Home (profile, Join/Host, recents),
|
/// Everything outside a session: one top bar (profile, Join/Host tabs,
|
||||||
/// Settings, Integrations, About behind a slim rail.
|
/// Settings/Integrations/About, window buttons) over a full-width body.
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return const Scaffold(body: HomeShell(windowControls: true));
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
WindowChromeBar(title: appName),
|
|
||||||
Expanded(child: HomeShell()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [HomeScreen] minus the window chrome (which needs window_manager), so
|
/// [HomeScreen]'s content. Tests pump it with [windowControls] off (the
|
||||||
/// tests can pump it.
|
/// drag area and window buttons need window_manager) and a fake [healthGet].
|
||||||
class HomeShell extends StatefulWidget {
|
class HomeShell extends StatefulWidget {
|
||||||
final UpdateController? updates;
|
final UpdateController? updates;
|
||||||
final bool autoUpdateCheck;
|
final bool autoUpdateCheck;
|
||||||
const HomeShell({super.key, this.updates, this.autoUpdateCheck = true});
|
final bool windowControls;
|
||||||
|
final HealthGetter healthGet;
|
||||||
|
const HomeShell({
|
||||||
|
super.key,
|
||||||
|
this.updates,
|
||||||
|
this.autoUpdateCheck = true,
|
||||||
|
this.windowControls = false,
|
||||||
|
this.healthGet = defaultHealthGet,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeShell> createState() => _HomeShellState();
|
State<HomeShell> createState() => _HomeShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeShellState extends State<HomeShell> {
|
class _HomeShellState extends State<HomeShell> {
|
||||||
int _tab = 0;
|
HomeView _view = HomeView.join;
|
||||||
|
final _flow = ConnectFlow();
|
||||||
static const _destinations = [
|
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||||
(Icons.home_outlined, Icons.home, 'Home'),
|
late final _host = HostFields(SessionState.instance.lastForm);
|
||||||
(Icons.settings_outlined, Icons.settings, 'Settings'),
|
late final void Function() _stopKickWatch;
|
||||||
(Icons.extension_outlined, Icons.extension, 'Integrations'),
|
late final void Function() _stopCrashWatch;
|
||||||
(Icons.info_outline, Icons.info, 'About'),
|
bool _dialogOpen = false;
|
||||||
];
|
String? _lastKickShown;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Profile.instance.ensureLoaded();
|
Profile.instance.ensureLoaded();
|
||||||
if (widget.autoUpdateCheck) _autoCheck();
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
|
_flow.onKicked = _showKickedDialog;
|
||||||
|
// Back from a session that ended in a kick: say so.
|
||||||
|
_stopKickWatch = effect(() {
|
||||||
|
final kick = SessionState.instance.coordinator.kickReason.value;
|
||||||
|
if (kick == null || kick.isEmpty) {
|
||||||
|
_lastKickShown = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
||||||
|
});
|
||||||
|
// Back from a session whose hosted server crashed: say so on the Host tab.
|
||||||
|
_stopCrashWatch = effect(() {
|
||||||
|
final crash = SessionState.instance.serverCrash.value;
|
||||||
|
if (crash == null) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
SessionState.instance.serverCrash.value = null;
|
||||||
|
if (!mounted) return;
|
||||||
|
_flow.showError(ConnectMode.host, CardError('Server crashed', crash));
|
||||||
|
setState(() => _view = HomeView.host);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stopKickWatch();
|
||||||
|
_stopCrashWatch();
|
||||||
|
_flow.dispose();
|
||||||
|
_join.dispose();
|
||||||
|
_host.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _autoCheck() async {
|
Future<void> _autoCheck() async {
|
||||||
@@ -63,40 +100,69 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showKickedDialog(String reason) async {
|
||||||
|
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
|
||||||
|
_lastKickShown = reason;
|
||||||
|
_dialogOpen = true;
|
||||||
|
try {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('You were removed'),
|
||||||
|
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
|
||||||
|
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_dialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _select(HomeView v) => setState(() => _view = v);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Row(
|
// Only the visible view is mounted: the Join tab polls /health only
|
||||||
children: [
|
// while shown. Form state lives here, so nothing is lost on switch.
|
||||||
NavigationRail(
|
final body = switch (_view) {
|
||||||
selectedIndex: _tab,
|
HomeView.join =>
|
||||||
onDestinationSelected: (i) => setState(() => _tab = i),
|
JoinTab(flow: _flow, fields: _join, healthGet: widget.healthGet),
|
||||||
labelType: NavigationRailLabelType.none,
|
HomeView.host => HostTab(
|
||||||
minWidth: 64,
|
flow: _flow,
|
||||||
backgroundColor: scheme.surfaceContainerLowest,
|
fields: _host,
|
||||||
indicatorShape:
|
onOpenIntegrations: () => _select(HomeView.integrations),
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
),
|
||||||
destinations: [
|
HomeView.settings => SettingsPage(updates: widget.updates),
|
||||||
for (final (icon, selected, label) in _destinations)
|
HomeView.integrations => const IntegrationsPage(),
|
||||||
NavigationRailDestination(
|
HomeView.about => AboutPage(updates: widget.updates),
|
||||||
icon: Tooltip(message: label, child: Icon(icon)),
|
};
|
||||||
selectedIcon: Tooltip(message: label, child: Icon(selected)),
|
return CallbackShortcuts(
|
||||||
label: Text(label),
|
bindings: {
|
||||||
),
|
const SingleActivator(LogicalKeyboardKey.digit1, control: true): () =>
|
||||||
|
_select(HomeView.join),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.digit2, control: true): () =>
|
||||||
|
_select(HomeView.host),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.comma, control: true): () =>
|
||||||
|
_select(HomeView.settings),
|
||||||
|
},
|
||||||
|
child: FocusScope(
|
||||||
|
autofocus: true,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
HomeTopBar(
|
||||||
|
view: _view,
|
||||||
|
onSelect: _select,
|
||||||
|
updates: widget.updates,
|
||||||
|
windowControls: widget.windowControls,
|
||||||
|
),
|
||||||
|
Divider(height: 1, thickness: 1, color: scheme.outlineVariant.withValues(alpha: 0.4)),
|
||||||
|
Expanded(
|
||||||
|
child: KeyedSubtree(key: ValueKey(_view), child: body),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: IndexedStack(
|
|
||||||
index: _tab,
|
|
||||||
children: [
|
|
||||||
HomePage(updates: widget.updates),
|
|
||||||
SettingsPage(updates: widget.updates),
|
|
||||||
const IntegrationsPage(),
|
|
||||||
AboutPage(updates: widget.updates),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import 'connect_flow.dart';
|
||||||
|
import 'connect_status.dart';
|
||||||
|
import 'media_picker.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
/// Host form state, owned by the Home shell so it survives tab switches.
|
||||||
|
class HostFields {
|
||||||
|
HostFields(ConnectForm f)
|
||||||
|
: port = TextEditingController(text: f.hostPort),
|
||||||
|
secret = TextEditingController(text: f.hostSecret),
|
||||||
|
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl);
|
||||||
|
final TextEditingController port;
|
||||||
|
final TextEditingController secret;
|
||||||
|
final MediaPickerController media;
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
port.dispose();
|
||||||
|
secret.dispose();
|
||||||
|
media.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Below this width the server column moves under the media picker.
|
||||||
|
const hostStackBelow = 860.0;
|
||||||
|
const _columnWidth = 340.0;
|
||||||
|
|
||||||
|
/// Media picker as the main area; port/password/Host in a narrow column.
|
||||||
|
class HostTab extends StatelessWidget {
|
||||||
|
final ConnectFlow flow;
|
||||||
|
final HostFields fields;
|
||||||
|
final VoidCallback? onOpenIntegrations;
|
||||||
|
const HostTab(
|
||||||
|
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
|
||||||
|
|
||||||
|
void _host() => flow.submit(ConnectMode.host, (f) {
|
||||||
|
final media = fields.media.selection;
|
||||||
|
f
|
||||||
|
..hostPort = fields.port.text.trim()
|
||||||
|
..hostSecret = fields.secret.text
|
||||||
|
..mediaPath = media.mediaPath
|
||||||
|
..linkUrl = media.linkUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: flow,
|
||||||
|
builder: (context, _) {
|
||||||
|
final busy = flow.busy == ConnectMode.host;
|
||||||
|
final picker = Column(
|
||||||
|
key: const ValueKey('host-picker'),
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('What to watch', style: t.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
MediaPicker(
|
||||||
|
controller: fields.media,
|
||||||
|
enabled: !busy,
|
||||||
|
autofocus: true,
|
||||||
|
onSubmit: _host,
|
||||||
|
onOpenIntegrations: onOpenIntegrations,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final stacked = box.maxWidth < hostStackBelow;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: kPagePadding,
|
||||||
|
child: stacked
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
picker,
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
|
||||||
|
child: column,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: picker),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
SizedBox(width: _columnWidth, child: column),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerColumn extends StatelessWidget {
|
||||||
|
final ConnectFlow flow;
|
||||||
|
final HostFields fields;
|
||||||
|
final VoidCallback onHost;
|
||||||
|
const _ServerColumn({required this.flow, required this.fields, required this.onHost});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final t = theme.textTheme;
|
||||||
|
return Panel(
|
||||||
|
key: const ValueKey('host-column'),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.cast, size: 20, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text('Your server', style: t.titleMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Runs on this computer. Friends need this port reachable.',
|
||||||
|
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: kGap),
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: flow.busy == ConnectMode.host,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('host-port'),
|
||||||
|
controller: fields.port,
|
||||||
|
decoration: fieldDecoration('Port'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||||
|
onSubmitted: (_) => onHost(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: fields.secret,
|
||||||
|
builder: (context, _) => TextField(
|
||||||
|
key: const ValueKey('host-password'),
|
||||||
|
controller: fields.secret,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: fieldDecoration('Password').copyWith(
|
||||||
|
helperText:
|
||||||
|
fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => onHost(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: fields.media,
|
||||||
|
builder: (context, _) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(_mediaSummary(fields.media.selection),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||||
|
const SizedBox(height: kGap),
|
||||||
|
ConnectButton(
|
||||||
|
flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost, expand: true),
|
||||||
|
_HostedRecents(fields: fields, enabled: flow.busy == null),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _mediaSummary(MediaSelection s) {
|
||||||
|
final raw = s.linkUrl.isNotEmpty ? s.linkUrl : s.mediaPath;
|
||||||
|
if (raw.isEmpty) return 'No media selected (optional).';
|
||||||
|
final name = raw.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => raw);
|
||||||
|
return 'Playing: $name';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ports hosted before: one click refills port and password.
|
||||||
|
class _HostedRecents extends StatelessWidget {
|
||||||
|
final HostFields fields;
|
||||||
|
final bool enabled;
|
||||||
|
const _HostedRecents({required this.fields, required this.enabled});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final list = [
|
||||||
|
for (final r in SessionState.instance.recents.value)
|
||||||
|
if (r.hosted) r
|
||||||
|
];
|
||||||
|
if (list.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: kGap),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final r in list)
|
||||||
|
InputChip(
|
||||||
|
key: ValueKey('recent-${r.key}'),
|
||||||
|
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
|
||||||
|
label: Text('Port ${r.port}'),
|
||||||
|
tooltip: 'Use port ${r.port} again',
|
||||||
|
isEnabled: enabled,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
onPressed: () {
|
||||||
|
fields.port.text = r.port;
|
||||||
|
fields.secret.text = r.secret;
|
||||||
|
},
|
||||||
|
deleteButtonTooltipMessage: 'Forget',
|
||||||
|
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import 'connect_flow.dart';
|
||||||
|
import 'connect_status.dart';
|
||||||
|
import 'health_probe.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
/// Join form text, owned by the Home shell so it survives tab switches.
|
||||||
|
class JoinFields {
|
||||||
|
JoinFields(ConnectForm f)
|
||||||
|
: host = TextEditingController(text: f.host),
|
||||||
|
port = TextEditingController(text: f.port),
|
||||||
|
secret = TextEditingController(text: f.secret);
|
||||||
|
final TextEditingController host;
|
||||||
|
final TextEditingController port;
|
||||||
|
final TextEditingController secret;
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
host.dispose();
|
||||||
|
port.dispose();
|
||||||
|
secret.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smallest recent-server tile; the grid adds columns as the window widens.
|
||||||
|
const _minTileWidth = 300.0;
|
||||||
|
|
||||||
|
/// One compact address/port/password row, then recent servers with live
|
||||||
|
/// status, as a grid that fills the width.
|
||||||
|
class JoinTab extends StatelessWidget {
|
||||||
|
final ConnectFlow flow;
|
||||||
|
final JoinFields fields;
|
||||||
|
final HealthGetter healthGet;
|
||||||
|
const JoinTab(
|
||||||
|
{super.key, required this.flow, required this.fields, required this.healthGet});
|
||||||
|
|
||||||
|
void _join() => flow.submit(ConnectMode.join, (f) {
|
||||||
|
f
|
||||||
|
..host = fields.host.text
|
||||||
|
..port = fields.port.text.trim()
|
||||||
|
..secret = fields.secret.text;
|
||||||
|
});
|
||||||
|
|
||||||
|
void _joinRecent(RecentServer r) {
|
||||||
|
fields.host.text = r.host;
|
||||||
|
fields.port.text = r.port;
|
||||||
|
fields.secret.text = r.secret;
|
||||||
|
_join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: flow,
|
||||||
|
builder: (context, _) => SingleChildScrollView(
|
||||||
|
padding: kPagePadding,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text('Join a friend’s server', style: t.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: flow.busy == ConnectMode.join,
|
||||||
|
child: _joinRow(),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
|
||||||
|
child: ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text('Recent servers', style: t.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SignalBuilder(builder: (context) {
|
||||||
|
final list = [
|
||||||
|
for (final r in SessionState.instance.recents.value)
|
||||||
|
if (!r.hosted) r
|
||||||
|
];
|
||||||
|
return RecentServersGrid(
|
||||||
|
servers: list,
|
||||||
|
enabled: flow.busy == null,
|
||||||
|
healthGet: healthGet,
|
||||||
|
onJoin: _joinRecent,
|
||||||
|
onForget: SessionState.instance.forgetServer,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _joinRow() {
|
||||||
|
return Wrap(
|
||||||
|
key: const ValueKey('join-row'),
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 280,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('join-address'),
|
||||||
|
controller: fields.host,
|
||||||
|
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
|
||||||
|
onChanged: (_) => flow.clearError(ConnectMode.join),
|
||||||
|
onSubmitted: (_) => _join(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Text(':', style: TextStyle(fontSize: 18, color: Colors.grey)),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 96,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('join-port'),
|
||||||
|
controller: fields.port,
|
||||||
|
decoration: fieldDecoration('Port'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onSubmitted: (_) => _join(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('join-password'),
|
||||||
|
controller: fields.secret,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: fieldDecoration('Password', hint: 'If the server has one'),
|
||||||
|
onSubmitted: (_) => _join(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recent servers as tiles; polls each one's `/health` every
|
||||||
|
/// [healthPollEvery] while mounted (i.e. while the Join tab is visible).
|
||||||
|
class RecentServersGrid extends StatefulWidget {
|
||||||
|
final List<RecentServer> servers;
|
||||||
|
final bool enabled;
|
||||||
|
final HealthGetter healthGet;
|
||||||
|
final ValueChanged<RecentServer> onJoin;
|
||||||
|
final ValueChanged<RecentServer> onForget;
|
||||||
|
const RecentServersGrid({
|
||||||
|
super.key,
|
||||||
|
required this.servers,
|
||||||
|
required this.enabled,
|
||||||
|
required this.healthGet,
|
||||||
|
required this.onJoin,
|
||||||
|
required this.onForget,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RecentServersGrid> createState() => _RecentServersGridState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecentServersGridState extends State<RecentServersGrid> {
|
||||||
|
final _health = <String, ServerHealth>{};
|
||||||
|
final _inFlight = <String>{};
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pollAll();
|
||||||
|
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(RecentServersGrid old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
for (final r in widget.servers) {
|
||||||
|
if (!_health.containsKey(r.key)) _poll(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pollAll() {
|
||||||
|
for (final r in widget.servers) {
|
||||||
|
_poll(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _poll(RecentServer r) async {
|
||||||
|
if (!_inFlight.add(r.key)) return;
|
||||||
|
try {
|
||||||
|
final h = await probeHealth(r.host, r.port, widget.healthGet);
|
||||||
|
if (mounted) setState(() => _health[r.key] = h);
|
||||||
|
} finally {
|
||||||
|
_inFlight.remove(r.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.servers.isEmpty) return const _EmptyRecents();
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final cols = ((box.maxWidth + kGap) / (_minTileWidth + kGap)).floor().clamp(1, 64);
|
||||||
|
final width = (box.maxWidth - kGap * (cols - 1)) / cols;
|
||||||
|
return Wrap(
|
||||||
|
spacing: kGap,
|
||||||
|
runSpacing: kGap,
|
||||||
|
children: [
|
||||||
|
for (final r in widget.servers)
|
||||||
|
SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: _RecentTile(
|
||||||
|
key: ValueKey('recent-${r.key}'),
|
||||||
|
server: r,
|
||||||
|
health: _health[r.key],
|
||||||
|
enabled: widget.enabled,
|
||||||
|
onJoin: () => widget.onJoin(r),
|
||||||
|
onForget: () => widget.onForget(r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecentTile extends StatelessWidget {
|
||||||
|
final RecentServer server;
|
||||||
|
/// Null until the first probe answers.
|
||||||
|
final ServerHealth? health;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onJoin;
|
||||||
|
final VoidCallback onForget;
|
||||||
|
const _RecentTile({
|
||||||
|
super.key,
|
||||||
|
required this.server,
|
||||||
|
required this.health,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onJoin,
|
||||||
|
required this.onForget,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
final t = theme.textTheme;
|
||||||
|
final h = health;
|
||||||
|
final address = '${server.host}:${server.port}';
|
||||||
|
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
|
||||||
|
final (status, statusColor) = switch (h) {
|
||||||
|
null => ('Checking…', Colors.grey),
|
||||||
|
ServerHealth(online: true) => (
|
||||||
|
'● online · ${h.viewers} watching',
|
||||||
|
Colors.greenAccent.shade400
|
||||||
|
),
|
||||||
|
_ => ('○ offline', Colors.grey),
|
||||||
|
};
|
||||||
|
return Material(
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(kRadius),
|
||||||
|
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onJoin : null,
|
||||||
|
child: Tooltip(
|
||||||
|
message: 'Join $address',
|
||||||
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 6, 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.dns_outlined, size: 20, color: scheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(title,
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
|
||||||
|
),
|
||||||
|
if (server.secret.isNotEmpty)
|
||||||
|
const Tooltip(
|
||||||
|
message: 'Password saved',
|
||||||
|
child: Icon(Icons.lock_outline, size: 16, color: Colors.grey),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Forget',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
iconSize: 18,
|
||||||
|
onPressed: onForget,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 30, right: 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(address,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(status,
|
||||||
|
key: const ValueKey('recent-status'),
|
||||||
|
style: t.bodyMedium?.copyWith(color: statusColor)),
|
||||||
|
),
|
||||||
|
if (h != null && h.needsUpdate)
|
||||||
|
Tooltip(
|
||||||
|
message: 'Server protocol ${h.proto}, this app '
|
||||||
|
'${RelayConstants.protoVersion} — update so both match',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text('needs update',
|
||||||
|
style: t.labelSmall
|
||||||
|
?.copyWith(color: scheme.onErrorContainer)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyRecents extends StatelessWidget {
|
||||||
|
const _EmptyRecents();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return Panel(
|
||||||
|
key: const ValueKey('recents-empty'),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.history, size: 28, color: Colors.grey),
|
||||||
|
const SizedBox(width: kGap),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('No recent servers yet', style: t.titleSmall),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('Servers you join show up here with live status; click one to rejoin.',
|
||||||
|
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../widgets/media_input.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
||||||
|
|
||||||
|
enum MediaKind { jellyfin, link, file }
|
||||||
|
|
||||||
|
/// What to play: at most one of the two is non-empty; both empty = none.
|
||||||
|
typedef MediaSelection = ({String mediaPath, String linkUrl});
|
||||||
|
|
||||||
|
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
|
||||||
|
/// later the session's swap panel) so it survives rebuilds.
|
||||||
|
class MediaPickerController extends ChangeNotifier {
|
||||||
|
MediaPickerController({String mediaPath = '', String linkUrl = '', MediaKind? source})
|
||||||
|
: link = TextEditingController(text: linkUrl),
|
||||||
|
file = TextEditingController(text: mediaPath),
|
||||||
|
_source = source ?? (linkUrl.isNotEmpty ? MediaKind.link : MediaKind.file) {
|
||||||
|
link.addListener(notifyListeners);
|
||||||
|
file.addListener(notifyListeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
final TextEditingController link;
|
||||||
|
final TextEditingController file;
|
||||||
|
MediaKind _source;
|
||||||
|
String? _notice;
|
||||||
|
|
||||||
|
MediaKind get source => _source;
|
||||||
|
set source(MediaKind v) {
|
||||||
|
if (v == _source) return;
|
||||||
|
_source = v;
|
||||||
|
_notice = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line hint under the fields (e.g. the file dialog failed).
|
||||||
|
String? get notice => _notice;
|
||||||
|
set notice(String? v) {
|
||||||
|
_notice = v;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaSelection get selection => switch (_source) {
|
||||||
|
MediaKind.link => (mediaPath: '', linkUrl: link.text.trim()),
|
||||||
|
MediaKind.file => (mediaPath: file.text.trim(), linkUrl: ''),
|
||||||
|
MediaKind.jellyfin => (mediaPath: '', linkUrl: ''),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Drops pasted text where it belongs: http(s) into Link, else File.
|
||||||
|
void pasteText(String text) {
|
||||||
|
final parsed = parseMediaInput(text);
|
||||||
|
if (parsed.linkUrl.isNotEmpty) {
|
||||||
|
link.text = parsed.linkUrl;
|
||||||
|
source = MediaKind.link;
|
||||||
|
} else if (parsed.mediaPath.isNotEmpty) {
|
||||||
|
file.text = parsed.mediaPath;
|
||||||
|
source = MediaKind.file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
link.dispose();
|
||||||
|
file.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source segments (Jellyfin / Link / File) and the matching input.
|
||||||
|
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
|
||||||
|
class MediaPicker extends StatefulWidget {
|
||||||
|
final MediaPickerController controller;
|
||||||
|
final ValueChanged<MediaSelection>? onChanged;
|
||||||
|
/// Enter in a field.
|
||||||
|
final VoidCallback? onSubmit;
|
||||||
|
final VoidCallback? onOpenIntegrations;
|
||||||
|
final bool enabled;
|
||||||
|
final bool autofocus;
|
||||||
|
const MediaPicker({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.onChanged,
|
||||||
|
this.onSubmit,
|
||||||
|
this.onOpenIntegrations,
|
||||||
|
this.enabled = true,
|
||||||
|
this.autofocus = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaPicker> createState() => _MediaPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaPickerState extends State<MediaPicker> {
|
||||||
|
MediaPickerController get _c => widget.controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_c.addListener(_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(MediaPicker old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.controller != widget.controller) {
|
||||||
|
old.controller.removeListener(_changed);
|
||||||
|
widget.controller.addListener(_changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_c.removeListener(_changed);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _changed() {
|
||||||
|
widget.onChanged?.call(_c.selection);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _clipboard() async {
|
||||||
|
try {
|
||||||
|
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pasteAnywhere() async {
|
||||||
|
final text = await _clipboard();
|
||||||
|
if (text != null && text.isNotEmpty) _c.pasteText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pasteLink() async {
|
||||||
|
final text = await _clipboard();
|
||||||
|
if (text != null && text.isNotEmpty) _c.link.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _browse() async {
|
||||||
|
try {
|
||||||
|
final files = await FilePicker.pickFiles(
|
||||||
|
dialogTitle: 'Pick media file',
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: videoExtensions,
|
||||||
|
);
|
||||||
|
final path = files.isEmpty ? null : files.first.path;
|
||||||
|
if (path != null) {
|
||||||
|
_c.file.text = path;
|
||||||
|
_c.notice = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||||
|
final ctrl = HardwareKeyboard.instance.isControlPressed ||
|
||||||
|
HardwareKeyboard.instance.isMetaPressed;
|
||||||
|
if (!widget.enabled || e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
// A focused text field handles its own paste.
|
||||||
|
final inField = FocusManager.instance.primaryFocus?.context
|
||||||
|
?.findAncestorStateOfType<EditableTextState>() !=
|
||||||
|
null;
|
||||||
|
if (inField) return KeyEventResult.ignored;
|
||||||
|
_pasteAnywhere();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final source = _c.source;
|
||||||
|
return Focus(
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
onKeyEvent: _onKey,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: !widget.enabled,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SegmentedButton<MediaKind>(
|
||||||
|
key: const ValueKey('media-source'),
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: MediaKind.jellyfin,
|
||||||
|
icon: Icon(Icons.video_library_outlined),
|
||||||
|
label: Text('Jellyfin')),
|
||||||
|
ButtonSegment(
|
||||||
|
value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
|
||||||
|
ButtonSegment(
|
||||||
|
value: MediaKind.file,
|
||||||
|
icon: Icon(Icons.insert_drive_file_outlined),
|
||||||
|
label: Text('File')),
|
||||||
|
],
|
||||||
|
selected: {source},
|
||||||
|
onSelectionChanged: (s) => _c.source = s.first,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: kGap),
|
||||||
|
switch (source) {
|
||||||
|
MediaKind.jellyfin =>
|
||||||
|
_JellyfinPlaceholder(onOpenIntegrations: widget.onOpenIntegrations),
|
||||||
|
MediaKind.link => _FieldRow(
|
||||||
|
key: const ValueKey('media-link'),
|
||||||
|
field: TextField(
|
||||||
|
controller: _c.link,
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||||
|
),
|
||||||
|
button: OutlinedButton.icon(
|
||||||
|
onPressed: _pasteLink,
|
||||||
|
icon: const Icon(Icons.content_paste, size: 18),
|
||||||
|
label: const Text('Paste'),
|
||||||
|
),
|
||||||
|
help: 'A direct http(s) link to a video file; everyone streams it from there.',
|
||||||
|
notice: _c.notice,
|
||||||
|
),
|
||||||
|
MediaKind.file => _FieldRow(
|
||||||
|
key: const ValueKey('media-file'),
|
||||||
|
field: TextField(
|
||||||
|
controller: _c.file,
|
||||||
|
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
|
||||||
|
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||||
|
),
|
||||||
|
button: OutlinedButton.icon(
|
||||||
|
onPressed: _browse,
|
||||||
|
icon: const Icon(Icons.folder_open, size: 18),
|
||||||
|
label: const Text('Browse'),
|
||||||
|
),
|
||||||
|
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||||
|
notice: _c.notice,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FieldRow extends StatelessWidget {
|
||||||
|
final Widget field;
|
||||||
|
final Widget button;
|
||||||
|
final String help;
|
||||||
|
final String? notice;
|
||||||
|
const _FieldRow(
|
||||||
|
{super.key, required this.field, required this.button, required this.help, this.notice});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey);
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: field),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
button,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(notice ?? help, style: small),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the Jellyfin library grid will go: ghost posters that fill the
|
||||||
|
/// width, dimmed, with the "coming soon" note on top.
|
||||||
|
class _JellyfinPlaceholder extends StatelessWidget {
|
||||||
|
final VoidCallback? onOpenIntegrations;
|
||||||
|
const _JellyfinPlaceholder({this.onOpenIntegrations});
|
||||||
|
|
||||||
|
static const _posterWidth = 140.0;
|
||||||
|
static const _rows = 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final cols = ((box.maxWidth + 12) / (_posterWidth + 12)).floor().clamp(1, 40);
|
||||||
|
return Stack(
|
||||||
|
key: const ValueKey('media-jellyfin'),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Opacity(
|
||||||
|
opacity: 0.35,
|
||||||
|
child: Column(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
for (var r = 0; r < _rows; r++)
|
||||||
|
Row(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
for (var c = 0; c < cols; c++)
|
||||||
|
Expanded(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Panel(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.video_library_outlined, size: 32, color: scheme.primary),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Link your Jellyfin in Integrations — coming soon',
|
||||||
|
textAlign: TextAlign.center, style: t.titleSmall),
|
||||||
|
if (onOpenIntegrations != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,7 +229,11 @@ Future<void> showProfileDialog(BuildContext context) {
|
|||||||
|
|
||||||
/// Home's top-left: avatar + name, click to edit.
|
/// Home's top-left: avatar + name, click to edit.
|
||||||
class ProfileChip extends StatelessWidget {
|
class ProfileChip extends StatelessWidget {
|
||||||
const ProfileChip({super.key});
|
/// One line and a small avatar, for the Home top bar.
|
||||||
|
final bool dense;
|
||||||
|
/// Just the avatar, for very narrow windows.
|
||||||
|
final bool avatarOnly;
|
||||||
|
const ProfileChip({super.key, this.dense = false, this.avatarOnly = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -239,31 +243,42 @@ class ProfileChip extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(kRadius),
|
borderRadius: BorderRadius.circular(kRadius),
|
||||||
onTap: () => showProfileDialog(context),
|
onTap: () => showProfileDialog(context),
|
||||||
child: Padding(
|
child: avatarOnly
|
||||||
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
|
? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14))
|
||||||
child: Row(
|
: Padding(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: dense
|
||||||
children: [
|
? const EdgeInsets.fromLTRB(4, 4, 10, 4)
|
||||||
const ProfileAvatar(radius: 20),
|
: const EdgeInsets.fromLTRB(6, 6, 14, 6),
|
||||||
const SizedBox(width: 12),
|
child: Row(
|
||||||
SignalBuilder(builder: (context) {
|
|
||||||
final n = p.name.value;
|
|
||||||
final t = Theme.of(context).textTheme;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(n.isEmpty ? 'Set your name' : n,
|
ProfileAvatar(radius: dense ? 14 : 20),
|
||||||
style: t.titleMedium?.copyWith(
|
SizedBox(width: dense ? 8 : 12),
|
||||||
color: n.isEmpty ? Colors.grey : null)),
|
SignalBuilder(builder: (context) {
|
||||||
Text('Edit profile',
|
final n = p.name.value;
|
||||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
final t = Theme.of(context).textTheme;
|
||||||
|
final name = Text(n.isEmpty ? 'Set your name' : n,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: (dense ? t.titleSmall : t.titleMedium)
|
||||||
|
?.copyWith(color: n.isEmpty ? Colors.grey : null));
|
||||||
|
if (dense) {
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 200), child: name);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
name,
|
||||||
|
Text('Edit profile',
|
||||||
|
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
}),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../widgets/window_buttons.dart';
|
||||||
|
import 'profile.dart';
|
||||||
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
|
/// What the Home body shows: the two tabs, or one of the side pages.
|
||||||
|
enum HomeView { join, host, settings, integrations, about }
|
||||||
|
|
||||||
|
const homeTopBarHeight = 48.0;
|
||||||
|
|
||||||
|
/// Below this width the bar drops the profile name and tab icons.
|
||||||
|
const _compactBelow = 760.0;
|
||||||
|
|
||||||
|
/// Home's single top bar: profile, Join/Host tabs, update pill, page
|
||||||
|
/// buttons and (in the real window) the drag area and window buttons.
|
||||||
|
class HomeTopBar extends StatelessWidget {
|
||||||
|
final HomeView view;
|
||||||
|
final ValueChanged<HomeView> onSelect;
|
||||||
|
final UpdateController? updates;
|
||||||
|
/// Off in tests: the drag area and window buttons need window_manager.
|
||||||
|
final bool windowControls;
|
||||||
|
const HomeTopBar({
|
||||||
|
super.key,
|
||||||
|
required this.view,
|
||||||
|
required this.onSelect,
|
||||||
|
this.updates,
|
||||||
|
this.windowControls = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _pages = [
|
||||||
|
(HomeView.settings, Icons.settings_outlined, Icons.settings, 'Settings'),
|
||||||
|
(HomeView.integrations, Icons.extension_outlined, Icons.extension, 'Integrations'),
|
||||||
|
(HomeView.about, Icons.info_outline, Icons.info, 'About'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, box) => _bar(context, compact: box.maxWidth < _compactBelow));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _bar(BuildContext context, {required bool compact}) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final row = Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ProfileChip(dense: true, avatarOnly: compact),
|
||||||
|
SizedBox(width: compact ? 8 : 20),
|
||||||
|
_Tab(
|
||||||
|
key: const ValueKey('tab-join'),
|
||||||
|
label: 'Join',
|
||||||
|
icon: compact ? null : Icons.login,
|
||||||
|
selected: view == HomeView.join,
|
||||||
|
onTap: () => onSelect(HomeView.join),
|
||||||
|
),
|
||||||
|
_Tab(
|
||||||
|
key: const ValueKey('tab-host'),
|
||||||
|
label: 'Host',
|
||||||
|
icon: compact ? null : Icons.cast,
|
||||||
|
selected: view == HomeView.host,
|
||||||
|
onTap: () => onSelect(HomeView.host),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
UpdateIndicator(controller: updates),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
for (final (v, icon, selectedIcon, label) in _pages)
|
||||||
|
IconButton(
|
||||||
|
tooltip: label,
|
||||||
|
isSelected: view == v,
|
||||||
|
icon: Icon(icon, size: 20),
|
||||||
|
selectedIcon: Icon(selectedIcon, size: 20, color: scheme.primary),
|
||||||
|
onPressed: () => onSelect(v),
|
||||||
|
),
|
||||||
|
if (windowControls) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
SizedBox(
|
||||||
|
height: 20,
|
||||||
|
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
const WindowButtons(),
|
||||||
|
] else
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final bar = SizedBox(height: homeTopBarHeight, child: row);
|
||||||
|
return ColoredBox(
|
||||||
|
color: scheme.surfaceContainerLowest,
|
||||||
|
// Empty bar space drags the window; the controls on top take their
|
||||||
|
// own clicks without waiting out the drag area's double-tap.
|
||||||
|
child: windowControls
|
||||||
|
? Stack(children: [
|
||||||
|
const Positioned.fill(child: DragToMoveArea(child: SizedBox.expand())),
|
||||||
|
bar,
|
||||||
|
])
|
||||||
|
: bar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Tab extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData? icon;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _Tab(
|
||||||
|
{super.key,
|
||||||
|
required this.label,
|
||||||
|
this.icon,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||||
|
return Semantics(
|
||||||
|
selected: selected,
|
||||||
|
button: true,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
height: homeTopBarHeight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Text(label,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: color, fontWeight: selected ? FontWeight.w600 : null)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -121,3 +121,43 @@ class PageScroll extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Widest a single text field gets, however wide the window.
|
||||||
|
const kFieldMaxWidth = 720.0;
|
||||||
|
|
||||||
|
/// Inline failure under a connect form: bold title, selectable message.
|
||||||
|
class ErrorBox extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
const ErrorBox({super.key, required this.title, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.errorContainer.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 18, color: scheme.error),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$title\n',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
TextSpan(text: message),
|
||||||
|
]),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/health_probe.dart';
|
||||||
import 'package:watch_together/src/screens/home/home_screen.dart';
|
import 'package:watch_together/src/screens/home/home_screen.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/ui.dart';
|
||||||
import 'package:watch_together/src/state/session_state.dart';
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
import 'package:watch_together/src/state/settings.dart';
|
import 'package:watch_together/src/state/settings.dart';
|
||||||
import 'package:watch_together/src/update/update_controller.dart';
|
import 'package:watch_together/src/update/update_controller.dart';
|
||||||
@@ -61,66 +67,225 @@ void main() {
|
|||||||
expect(s.hwdec.value, 'vaapi');
|
expect(s.hwdec.value, 'vaapi');
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpShell(WidgetTester tester, Size size) async {
|
test('health parse: online, junk and protocol mismatch', () {
|
||||||
|
final ok = ServerHealth.parse(jsonEncode(
|
||||||
|
{'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2}));
|
||||||
|
expect(ok.online, isTrue);
|
||||||
|
expect(ok.name, 'Den');
|
||||||
|
expect(ok.viewers, 2);
|
||||||
|
expect(ok.needsUpdate, isFalse);
|
||||||
|
expect(ServerHealth.parse('{"ok":true,"proto":-1}').needsUpdate, isTrue);
|
||||||
|
expect(ServerHealth.parse('nope').online, isFalse);
|
||||||
|
expect(ServerHealth.parse('{"ok":false}').online, isFalse);
|
||||||
|
expect(healthUri('::1', '8080').toString(), 'http://[::1]:8080/health');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasting sorts links and paths into the right source', () {
|
||||||
|
final c = MediaPickerController();
|
||||||
|
expect(c.source, MediaKind.file);
|
||||||
|
c.pasteText(' https://example.com/a.mkv ');
|
||||||
|
expect(c.source, MediaKind.link);
|
||||||
|
expect(c.selection, (mediaPath: '', linkUrl: 'https://example.com/a.mkv'));
|
||||||
|
c.pasteText('/home/me/b.mkv');
|
||||||
|
expect(c.source, MediaKind.file);
|
||||||
|
expect(c.selection, (mediaPath: '/home/me/b.mkv', linkUrl: ''));
|
||||||
|
c.source = MediaKind.jellyfin;
|
||||||
|
expect(c.selection, (mediaPath: '', linkUrl: ''));
|
||||||
|
c.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Fake `/health`: host -> JSON body; unknown hosts are unreachable.
|
||||||
|
HealthGetter fakeHealth(Map<String, Map<String, Object>> up) => (uri) async {
|
||||||
|
final body = up[uri.host];
|
||||||
|
if (body == null) throw Exception('connection refused');
|
||||||
|
return jsonEncode(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> pumpShell(WidgetTester tester, Size size, {HealthGetter? health}) async {
|
||||||
tester.view.physicalSize = size;
|
tester.view.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
theme: ThemeData.dark(),
|
theme: ThemeData.dark(),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: HomeShell(updates: UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false),
|
body: HomeShell(
|
||||||
|
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||||
|
autoUpdateCheck: false,
|
||||||
|
healthGet: health ?? fakeHealth(const {}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('home shows both cards side by side on a wide window', (tester) async {
|
setUp(() => SessionState.instance.recents.value = const []);
|
||||||
await pumpShell(tester, const Size(1280, 720));
|
|
||||||
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
testWidgets('tabs and page buttons switch the body', (tester) async {
|
||||||
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
await pumpShell(tester, const Size(1280, 900));
|
||||||
expect(join.top, host.top);
|
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||||
expect(join.height, host.height);
|
expect(find.byKey(const ValueKey('host-picker')), findsNothing);
|
||||||
expect(join.right, lessThan(host.left));
|
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('join-row')), findsNothing);
|
||||||
expect(find.text('No password — anyone can join'), findsOneWidget);
|
expect(find.text('No password — anyone can join'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Settings'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||||
|
expect(find.text('Check automatically on start'), findsOneWidget);
|
||||||
|
expect(find.text('Toggle ready'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Integrations'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Jellyfin'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('About'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('tab-join')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('cards stack on a narrow window', (tester) async {
|
testWidgets('Enter in the join row submits; errors show under it', (tester) async {
|
||||||
await pumpShell(tester, const Size(560, 900));
|
|
||||||
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
|
||||||
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
|
||||||
expect(join.bottom, lessThan(host.top));
|
|
||||||
expect(tester.takeException(), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('join without a name errors inline in the join card', (tester) async {
|
|
||||||
SessionState.instance.lastForm.name = '';
|
SessionState.instance.lastForm.name = '';
|
||||||
await pumpShell(tester, const Size(1280, 720));
|
await pumpShell(tester, const Size(1280, 720));
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
|
||||||
|
// The button submits too, and typing in the address clears the error.
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example2');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('display name', findRichText: true), findsNothing);
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('recent tiles show live status from /health', (tester) async {
|
||||||
|
SessionState.instance.recents.value = [
|
||||||
|
join('alpha.example', '8080'),
|
||||||
|
join('beta.example', '9000', 'pw'),
|
||||||
|
join('gamma.example', '7000'),
|
||||||
|
const RecentServer(hosted: true, host: '', port: '8181'),
|
||||||
|
];
|
||||||
|
await pumpShell(tester, const Size(1280, 900),
|
||||||
|
health: fakeHealth({
|
||||||
|
'alpha.example': {
|
||||||
|
'ok': true,
|
||||||
|
'name': 'Movie night',
|
||||||
|
'proto': RelayConstants.protoVersion,
|
||||||
|
'viewers': 3,
|
||||||
|
},
|
||||||
|
'gamma.example': {'ok': true, 'name': '', 'proto': -1, 'viewers': 0},
|
||||||
|
}));
|
||||||
|
await tester.pump();
|
||||||
|
Finder inTile(String key, Finder f) =>
|
||||||
|
find.descendant(of: find.byKey(ValueKey('recent-$key')), matching: f);
|
||||||
|
expect(inTile('alpha.example:8080', find.text('Movie night')), findsOneWidget);
|
||||||
|
expect(inTile('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget);
|
||||||
|
expect(inTile('alpha.example:8080', find.text('needs update')), findsNothing);
|
||||||
|
expect(inTile('beta.example:9000', find.text('○ offline')), findsOneWidget);
|
||||||
|
expect(inTile('beta.example:9000', find.text('beta.example')), findsOneWidget);
|
||||||
|
expect(inTile('gamma.example:7000', find.text('needs update')), findsOneWidget);
|
||||||
|
// Hosted entries live on the Host tab, not in the join grid.
|
||||||
|
expect(find.byKey(const ValueKey('recent-host:8181')), findsNothing);
|
||||||
|
// Tiles share a row on a wide window.
|
||||||
|
final a = tester.getRect(find.byKey(const ValueKey('recent-alpha.example:8080')));
|
||||||
|
final b = tester.getRect(find.byKey(const ValueKey('recent-beta.example:9000')));
|
||||||
|
expect(a.top, b.top);
|
||||||
|
// x forgets.
|
||||||
|
await tester.tap(inTile('beta.example:9000', find.byTooltip('Forget')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('recent-beta.example:9000')), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no recents shows the empty state', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(1280, 720));
|
||||||
|
expect(find.byKey(const ValueKey('recents-empty')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('wide windows: grid fills the width, fields stay compact', (tester) async {
|
||||||
|
SessionState.instance.recents.value = [
|
||||||
|
for (var i = 0; i < 6; i++) join('10.0.0.$i', '8080'),
|
||||||
|
];
|
||||||
|
await pumpShell(tester, const Size(3440, 1440));
|
||||||
|
final first = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.0:8080')));
|
||||||
|
final last = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.5:8080')));
|
||||||
|
expect(first.top, last.top);
|
||||||
|
expect(tester.getSize(find.byKey(const ValueKey('join-address'))).width, 280);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(find.byKey(const ValueKey('host-column'))).width, 340);
|
||||||
|
final file = find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('media-file')), matching: find.byType(TextField));
|
||||||
|
expect(tester.getSize(file).width, lessThanOrEqualTo(kFieldMaxWidth));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('media picker segments switch the input', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(1280, 900));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('media-file')), findsOneWidget);
|
||||||
|
expect(find.text('Browse'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Link'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('media-link')), findsOneWidget);
|
||||||
|
expect(find.text('Paste'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Jellyfin'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Link your Jellyfin in Integrations — coming soon'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Open Integrations'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Link account'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('host column sits right when wide, below when narrow', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(1280, 900));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
var picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
|
||||||
|
var column = tester.getRect(find.byKey(const ValueKey('host-column')));
|
||||||
|
expect(picker.right, lessThan(column.left));
|
||||||
|
expect(picker.top, column.top);
|
||||||
|
|
||||||
|
await pumpShell(tester, const Size(640, 1000));
|
||||||
|
picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
|
||||||
|
column = tester.getRect(find.byKey(const ValueKey('host-column')));
|
||||||
|
expect(picker.bottom, lessThan(column.top));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('host without a name errors in the server column', (tester) async {
|
||||||
|
SessionState.instance.lastForm.name = '';
|
||||||
|
await pumpShell(tester, const Size(1280, 900));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Host'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const ValueKey('join-card')),
|
of: find.byKey(const ValueKey('host-column')),
|
||||||
matching: find.textContaining('display name', findRichText: true),
|
matching: find.textContaining('display name', findRichText: true),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('rail switches to settings, integrations and about', (tester) async {
|
testWidgets('narrow window: bar, join row and host tab fit', (tester) async {
|
||||||
await pumpShell(tester, const Size(1280, 900));
|
await pumpShell(tester, const Size(480, 800));
|
||||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
await tester.tap(find.text('Jellyfin'));
|
||||||
expect(find.text('Check automatically on start'), findsOneWidget);
|
|
||||||
expect(find.text('Toggle ready'), findsOneWidget);
|
|
||||||
await tester.tap(find.byIcon(Icons.extension_outlined));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Jellyfin'), findsOneWidget);
|
|
||||||
await tester.tap(find.byIcon(Icons.info_outline));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user