diff --git a/README.md b/README.md index 5c0c4ac..cd1a3c7 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Download the latest `watch-together--linux-x64.tar.gz` from the Releases page of this repo, extract it, run `watch_together`. 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 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 @@ -33,9 +33,11 @@ telling you to update. ## Host from the app -Home, Host card. Pick a port, an optional password (blank = anyone can -join), and optionally a video: a link or a file path. Your name and avatar -come from your profile. The app starts a +Home, Host tab. Pick what to watch (optional): Link (paste an http(s) URL; +Ctrl+V outside a field pastes it in) or File (path or Browse). Jellyfin is +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. 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. @@ -70,9 +72,12 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use ## Join -Home, Join card. 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). -Servers you got into appear as up to 6 Recent chips on Home: one click rejoins. +Home, Join tab. Enter the host's address, port, and the password if there +is one, then Enter or Join. Your name and avatar come from your profile (top +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 diff --git a/apps/desktop/lib/src/screens/home/connect_status.dart b/apps/desktop/lib/src/screens/home/connect_status.dart new file mode 100644 index 0000000..041a554 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/connect_status.dart @@ -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(); + } +} diff --git a/apps/desktop/lib/src/screens/home/health_probe.dart b/apps/desktop/lib/src/screens/home/health_probe.dart new file mode 100644 index 0000000..cea4fc6 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/health_probe.dart @@ -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 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 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 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(); + throw HttpException('status ${res.statusCode}', uri: uri); + } + return await res.transform(utf8.decoder).join().timeout(_timeout); + } finally { + client.close(force: true); + } +} diff --git a/apps/desktop/lib/src/screens/home/home_page.dart b/apps/desktop/lib/src/screens/home/home_page.dart deleted file mode 100644 index 43bbc9a..0000000 --- a/apps/desktop/lib/src/screens/home/home_page.dart +++ /dev/null @@ -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 createState() => _HomePageState(); -} - -class _HomePageState extends State { - 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 _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 _showKickedDialog(String reason) async { - if (!mounted || _dialogOpen || reason == _lastKickShown) return; - _lastKickShown = reason; - _dialogOpen = true; - try { - await showDialog( - 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 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), - ), - ), - ], - ), - ); - } -} diff --git a/apps/desktop/lib/src/screens/home/home_screen.dart b/apps/desktop/lib/src/screens/home/home_screen.dart index 283bcd9..7488599 100644 --- a/apps/desktop/lib/src/screens/home/home_screen.dart +++ b/apps/desktop/lib/src/screens/home/home_screen.dart @@ -1,60 +1,97 @@ 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 '../../update/update_controller.dart'; -import '../../widgets/window_chrome_bar.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 'join_tab.dart'; import 'profile.dart'; import 'settings_page.dart'; -import 'ui.dart'; +import 'top_bar.dart'; import 'update_indicator.dart'; -/// Everything outside a session: Home (profile, Join/Host, recents), -/// Settings, Integrations, About behind a slim rail. +/// Everything outside a session: one top bar (profile, Join/Host tabs, +/// Settings/Integrations/About, window buttons) over a full-width body. class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { - return const Scaffold( - body: Column( - children: [ - WindowChromeBar(title: appName), - Expanded(child: HomeShell()), - ], - ), - ); + return const Scaffold(body: HomeShell(windowControls: true)); } } -/// [HomeScreen] minus the window chrome (which needs window_manager), so -/// tests can pump it. +/// [HomeScreen]'s content. Tests pump it with [windowControls] off (the +/// drag area and window buttons need window_manager) and a fake [healthGet]. class HomeShell extends StatefulWidget { final UpdateController? updates; 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 State createState() => _HomeShellState(); } class _HomeShellState extends State { - int _tab = 0; - - static const _destinations = [ - (Icons.home_outlined, Icons.home, 'Home'), - (Icons.settings_outlined, Icons.settings, 'Settings'), - (Icons.extension_outlined, Icons.extension, 'Integrations'), - (Icons.info_outline, Icons.info, 'About'), - ]; + HomeView _view = HomeView.join; + final _flow = ConnectFlow(); + late final _join = JoinFields(SessionState.instance.lastForm); + late final _host = HostFields(SessionState.instance.lastForm); + late final void Function() _stopKickWatch; + late final void Function() _stopCrashWatch; + bool _dialogOpen = false; + String? _lastKickShown; @override void initState() { super.initState(); Profile.instance.ensureLoaded(); 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 _autoCheck() async { @@ -63,40 +100,69 @@ class _HomeShellState extends State { if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates); } + Future _showKickedDialog(String reason) async { + if (!mounted || _dialogOpen || reason == _lastKickShown) return; + _lastKickShown = reason; + _dialogOpen = true; + try { + await showDialog( + 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 Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return Row( - children: [ - NavigationRail( - selectedIndex: _tab, - onDestinationSelected: (i) => setState(() => _tab = i), - labelType: NavigationRailLabelType.none, - minWidth: 64, - backgroundColor: scheme.surfaceContainerLowest, - indicatorShape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)), - destinations: [ - for (final (icon, selected, label) in _destinations) - NavigationRailDestination( - icon: Tooltip(message: label, child: Icon(icon)), - selectedIcon: Tooltip(message: label, child: Icon(selected)), - label: Text(label), - ), + // Only the visible view is mounted: the Join tab polls /health only + // while shown. Form state lives here, so nothing is lost on switch. + final body = switch (_view) { + HomeView.join => + JoinTab(flow: _flow, fields: _join, healthGet: widget.healthGet), + HomeView.host => HostTab( + flow: _flow, + fields: _host, + onOpenIntegrations: () => _select(HomeView.integrations), + ), + HomeView.settings => SettingsPage(updates: widget.updates), + HomeView.integrations => const IntegrationsPage(), + HomeView.about => AboutPage(updates: widget.updates), + }; + return CallbackShortcuts( + 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), - ], - ), - ), - ], + ), ); } } diff --git a/apps/desktop/lib/src/screens/home/host_tab.dart b/apps/desktop/lib/src/screens/home/host_tab.dart new file mode 100644 index 0000000..ecfd05f --- /dev/null +++ b/apps/desktop/lib/src/screens/home/host_tab.dart @@ -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), + ), + ], + ), + ); + }); + } +} diff --git a/apps/desktop/lib/src/screens/home/join_tab.dart b/apps/desktop/lib/src/screens/home/join_tab.dart new file mode 100644 index 0000000..f2f5d8d --- /dev/null +++ b/apps/desktop/lib/src/screens/home/join_tab.dart @@ -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 servers; + final bool enabled; + final HealthGetter healthGet; + final ValueChanged onJoin; + final ValueChanged onForget; + const RecentServersGrid({ + super.key, + required this.servers, + required this.enabled, + required this.healthGet, + required this.onJoin, + required this.onForget, + }); + + @override + State createState() => _RecentServersGridState(); +} + +class _RecentServersGridState extends State { + final _health = {}; + final _inFlight = {}; + 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 _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)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/media_picker.dart b/apps/desktop/lib/src/screens/home/media_picker.dart new file mode 100644 index 0000000..1ed1829 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/media_picker.dart @@ -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? 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 createState() => _MediaPickerState(); +} + +class _MediaPickerState extends State { + 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 _clipboard() async { + try { + return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim(); + } catch (_) { + return null; + } + } + + Future _pasteAnywhere() async { + final text = await _clipboard(); + if (text != null && text.isNotEmpty) _c.pasteText(text); + } + + Future _pasteLink() async { + final text = await _clipboard(); + if (text != null && text.isNotEmpty) _c.link.text = text; + } + + Future _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() != + 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( + 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')), + ], + ], + ), + ), + ], + ); + }); + } +} diff --git a/apps/desktop/lib/src/screens/home/profile.dart b/apps/desktop/lib/src/screens/home/profile.dart index 0cef6b8..8588a2a 100644 --- a/apps/desktop/lib/src/screens/home/profile.dart +++ b/apps/desktop/lib/src/screens/home/profile.dart @@ -229,7 +229,11 @@ Future showProfileDialog(BuildContext context) { /// Home's top-left: avatar + name, click to edit. 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 Widget build(BuildContext context) { @@ -239,31 +243,42 @@ class ProfileChip extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(kRadius), onTap: () => showProfileDialog(context), - child: Padding( - padding: const EdgeInsets.fromLTRB(6, 6, 14, 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const ProfileAvatar(radius: 20), - const SizedBox(width: 12), - SignalBuilder(builder: (context) { - final n = p.name.value; - final t = Theme.of(context).textTheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: avatarOnly + ? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14)) + : Padding( + padding: dense + ? const EdgeInsets.fromLTRB(4, 4, 10, 4) + : const EdgeInsets.fromLTRB(6, 6, 14, 6), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(n.isEmpty ? 'Set your name' : n, - style: t.titleMedium?.copyWith( - color: n.isEmpty ? Colors.grey : null)), - Text('Edit profile', - style: t.bodySmall?.copyWith(color: Colors.grey)), + ProfileAvatar(radius: dense ? 14 : 20), + SizedBox(width: dense ? 8 : 12), + SignalBuilder(builder: (context) { + final n = p.name.value; + 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)), + ], + ); + }), ], - ); - }), - ], - ), - ), + ), + ), ), ); } diff --git a/apps/desktop/lib/src/screens/home/top_bar.dart b/apps/desktop/lib/src/screens/home/top_bar.dart new file mode 100644 index 0000000..38643b5 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/top_bar.dart @@ -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 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)), + ], + ), + ), + ), + ); + } +} + diff --git a/apps/desktop/lib/src/screens/home/ui.dart b/apps/desktop/lib/src/screens/home/ui.dart index a5ef2e7..4b8c147 100644 --- a/apps/desktop/lib/src/screens/home/ui.dart +++ b/apps/desktop/lib/src/screens/home/ui.dart @@ -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), + ), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 086adbc..5b08f06 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -1,6 +1,12 @@ +import 'dart:convert'; + import 'package:flutter/material.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/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/settings.dart'; import 'package:watch_together/src/update/update_controller.dart'; @@ -61,66 +67,225 @@ void main() { expect(s.hwdec.value, 'vaapi'); }); - Future 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> up) => (uri) async { + final body = up[uri.host]; + if (body == null) throw Exception('connection refused'); + return jsonEncode(body); + }; + + Future pumpShell(WidgetTester tester, Size size, {HealthGetter? health}) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp( theme: ThemeData.dark(), 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(); } - testWidgets('home shows both cards side by side on a wide window', (tester) async { - await pumpShell(tester, const Size(1280, 720)); - final join = tester.getRect(find.byKey(const ValueKey('join-card'))); - final host = tester.getRect(find.byKey(const ValueKey('host-card'))); - expect(join.top, host.top); - expect(join.height, host.height); - expect(join.right, lessThan(host.left)); + setUp(() => SessionState.instance.recents.value = const []); + + testWidgets('tabs and page buttons switch the body', (tester) async { + await pumpShell(tester, const Size(1280, 900)); + expect(find.byKey(const ValueKey('join-row')), findsOneWidget); + expect(find.byKey(const ValueKey('host-picker')), findsNothing); + 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); + 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); }); - testWidgets('cards stack on a narrow window', (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 { + testWidgets('Enter in the join row submits; errors show under it', (tester) async { SessionState.instance.lastForm.name = ''; 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.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( find.descendant( - of: find.byKey(const ValueKey('join-card')), + of: find.byKey(const ValueKey('host-column')), matching: find.textContaining('display name', findRichText: true), ), findsOneWidget, ); }); - testWidgets('rail switches to settings, integrations and about', (tester) async { - await pumpShell(tester, const Size(1280, 900)); - await tester.tap(find.byIcon(Icons.settings_outlined)); + testWidgets('narrow window: bar, join row and host tab fit', (tester) async { + await pumpShell(tester, const Size(480, 800)); + expect(find.byKey(const ValueKey('join-row')), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('tab-host'))); 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.byIcon(Icons.extension_outlined)); + await tester.tap(find.text('Jellyfin')); 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); }); }