diff --git a/README.md b/README.md index 5f3b1c9..d9f1068 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ From the Releases page of this repo: Windows desktop; reports welcome. Update from the app: when a newer version exists, an update indicator -appears in Home's top bar and opens the update card. "Update and restart" downloads, +appears in the strip at the top of the window 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 the shipped `update.sh` / `update.bat`. A @@ -36,19 +36,51 @@ release with a newer protocol is a required update: everyone on a server must run the same protocol version, and a mismatch is refused with a message telling you to update. +## The window + +One window. Along the top, the strip: your avatar (click it for your +profile: name, picture with a crop editor, colour, and links to the +settings), the Library / Schedule / Settings tabs, and the party. Offline +the party side has Join and Host; in a server it shows everyone's avatar +with a ring for their state (green ready, amber buffering, grey not ready, +dimmed reconnecting; a crown on yours when you host, a tower on whoever +passes the video on). Click the avatars for the member list (force ready, +kick). Beside them the server and "ready n/m", the chat button with unread +count, and Leave. Empty strip space drags the window. + +The player fills the window under the strip. Pick a tab and it shrinks to +a mini player in a corner: drag it anywhere and it snaps to the nearest +corner; double-click or ⤢ makes it big again, × stops watching (you stay in +the server, muted) and the strip's screen button brings it back. F or +double-click on the picture goes fullscreen (the strip hides, the party +shows in a corner while paused), Esc leaves it. While playback waits, a +pill under the picture says for whom ("Waiting for you · press Space when +ready"). Chat and activity open in a panel on the right (chat button or +Tab); joins, leaves, chat and other news also pop up bottom left with the +person's avatar. + +Your name and picture change for everyone in the server right away; the +colour is yours only. + ## Host from the app -Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked -servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field -pastes it in) or File (path or Browse). 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. +Host in the strip: set a port and an optional password (blank = anyone can +join), then Host. The app starts a server inside itself and joins it; you +are the host and nobody can kick you. Nothing is up yet: pick what to +watch in the Library tab (below) and "Put it up for everyone". 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. Ports you hosted on before are one click away in the Host card. -If you pick a local file, the server streams it to everyone from your disk. -Nothing is uploaded or stored anywhere. +If you put up a local file, the server streams it to everyone from your +disk. Nothing is uploaded or stored anywhere. + +## Library + +What to watch: Jellyfin (browse your linked servers, see below), Link +(paste an http(s) URL; Ctrl+V outside a field pastes it in) or File (path +or Browse), then "Put it up for everyone" and the player opens. Offline the +library browses but nothing goes up until you join or host. ## Dedicated server @@ -77,29 +109,30 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use ## Join -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. +Join in the strip. Servers you got into are listed first (up to 6), 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. Or enter the host's address, port, and the password if there +is one, then Enter or Join. Your name and picture come from your profile +(your avatar at the top left). ## Settings -Profile, hardware decoding (auto, off or custom; the env var -`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), -"Experimental: mpv-style controls" (below), updates, playing the next -Jellyfin episode automatically, video shaders (see below), and the -keyboard shortcuts (read-only). -Integrations links Jellyfin servers (below). About shows the version. +The Settings tab (Ctrl+,) has a page per topic: Profile; Player (hardware +decoding: auto, off or custom, the env var `SYNC_RELAY_HWDEC` overrides +it, applies on the next join or Reload video; "Experimental: mpv-style +controls", below; video shaders, see below); Jellyfin & integrations +(linked Jellyfin servers, below, and playing the next episode +automatically); Updates; Keyboard shortcuts (read-only); About (the +version). -Keys in a session: Space = ready / unready, Tab = side panel (people, log, -chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30, +Keys while the player fills the window: Space = ready / unready, Tab = +chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30, PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = -close panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / -Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B, -C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump -moves everyone. +close the chat or fullscreen, else ask to leave, Ctrl+R = reload video, +`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode, +Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off. +Every seek, skip and chapter jump moves everyone. Controls: the seek bar marks chapter starts with a diamond; hovering it shows the time and the chapter title. The chapters button lists them @@ -114,8 +147,9 @@ timeline across the bottom shows the played part, the cached part ahead chapter title and the time there. With the controls hidden it stays as a thin line. Above it flat buttons: ≡ menu, Subtitles and Audio (with the track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), -ready, chapters, 1:30, previous / next episode, change video, people, -volume and fullscreen. Keys and skip buttons work the same. +ready, chapters, 1:30, previous / next episode, change video, volume and +fullscreen; chat and back-to-library top right. Keys and skip buttons work +the same. Skipping: during the opening a "Skip OP" button floats in the corner (it jumps to the end of the opening), during the ending "Skip ED" (to the end @@ -137,29 +171,27 @@ Only the host needs the port reachable. ## Jellyfin -Integrations > Jellyfin > Add server: type the address, then sign in with +Settings > Jellyfin & integrations > Add server: type the address, then sign in with Quick Connect (the app shows a code; approve it in Jellyfin under Profile > Quick Connect) or your username and password. Several servers can be linked; each shows its status, and a star picks the default. Sign out revokes the app's sign-in on the server. If a sign-in expires the server shows "Reconnect" instead of disappearing. -The Host tab's Jellyfin source is a library browser: Continue watching (with +The Library's Jellyfin source is a library browser: Continue watching (with time left), Next up and the latest per library on the start page; library grids with sort (name, date added, release date, rating, random) and Unwatched / Favorites filters; series pages with seasons, episode progress and a "Next up" button; details with the file's tracks; search. Clicking a -movie or episode picks it; the button in the top right enlarges the browser -and moves port, password and Host into a bar below it. On wide windows a -side pane shows details of whatever is under the mouse. +movie or episode picks it. On wide windows a side pane shows details of +whatever is under the mouse. Keys in the browser: arrows move, Enter picks or opens, I = details, Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F = search. -Host from the library: pick a movie or episode and Host. Only the item -goes to the server (which Jellyfin server and which item), never a link or -your sign-in. Pasting a Jellyfin link (web page or stream link) does the +Putting up a movie or episode sends only the item to the server (which +Jellyfin server and which item), never a link or your sign-in. Pasting a Jellyfin link (web page or stream link) does the same when the app can reach that Jellyfin; otherwise it is shared as a plain link. @@ -172,7 +204,7 @@ Watching: - Friends without an account watch through the server: whoever put the item up passes it on automatically, with their own sign-in, which never leaves their computer (only the current item, only to people in the - session). They show "via host" in the member list. When the app hosts, + session). They show "via host" in the member list (click the avatars). When the app hosts, or a dedicated server has its own login (`--jellyfin `, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server fetches it itself instead. @@ -195,9 +227,9 @@ Your choice is remembered per series (and as a default) and the closest match (language, then title, then codec and channels) is selected on the next episode. This is per person; nobody else is affected. -"Change video" in the session panel (Tab) opens the same picker as the Host -tab: the library starts on the series you are watching with the current -season, else where you left it. +"Change video" in the player's controls opens the Library tab (the player +goes mini): the library starts on the series you are watching with the +current season, else where you left it. Anyone can put up a file from their own computer, also on a dedicated server: it streams from your computer through the server to everyone (the @@ -226,7 +258,7 @@ C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast Pick one with the ✨ button in the player's control bar (also in fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next -session starts with it. Settings → Video quality & shaders sets the default, +session starts with it. Settings → Player sets the default, HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time every frame and need hardware video rendering: on media_kit's software fallback they do nothing, and the app says so. A shader that is missing or diff --git a/apps/desktop/lib/app.dart b/apps/desktop/lib/app.dart index 3f57e3d..515a5b0 100644 --- a/apps/desktop/lib/app.dart +++ b/apps/desktop/lib/app.dart @@ -1,28 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:signals/signals_flutter.dart'; -import 'src/screens/home/home_screen.dart'; import 'src/screens/home/ui.dart'; -import 'src/screens/session_screen.dart'; -import 'src/state/session_state.dart'; +import 'src/shell/app_shell.dart'; class WatchTogetherApp extends StatelessWidget { const WatchTogetherApp({super.key}); @override Widget build(BuildContext context) { - final session = SessionState.instance; return MaterialApp( title: appName, theme: ThemeData.dark(), - home: SignalBuilder( - builder: (context) { - return switch (session.route.value) { - AppRoute.home => const HomeScreen(), - AppRoute.session => const SessionScreen(), - }; - }, - ), + home: const AppShell(), ); } } diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart index 4ae467a..c505610 100644 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -6,11 +6,10 @@ import 'package:signals/signals_flutter.dart'; import '../../state/settings.dart'; // The experimental mpv-style (uosc) controls frame: a full-width timeline -// at the bottom with a row of flat buttons above it, window buttons top +// at the bottom with a row of flat buttons above it, a corner widget top // right. Mouse movement shows them; after [UoscControls.hideAfter] without // any they fade out, the timeline shrinks to a thin line and the cursor -// hides. Everything lives here because media_kit's fullscreen route -// renders only the video and its controls. +// hides. Our own frame: no media_kit controls state to reach into. class UoscControls extends StatefulWidget { /// The timeline; [expanded] is false while the controls are hidden. @@ -18,7 +17,7 @@ class UoscControls extends StatefulWidget { /// Buttons left and right of the row above the timeline. final List left; final List right; - /// Top-right corner (window buttons), shown with the controls. + /// Top-right corner (chat, back to the library), shown with the controls. final Widget? top; final VoidCallback? onDoubleTap; final Duration hideAfter; diff --git a/apps/desktop/lib/src/jellyfin/accounts.dart b/apps/desktop/lib/src/jellyfin/accounts.dart index dc26ce4..0b0bc58 100644 --- a/apps/desktop/lib/src/jellyfin/accounts.dart +++ b/apps/desktop/lib/src/jellyfin/accounts.dart @@ -207,7 +207,7 @@ class JellyfinAccounts extends ChangeNotifier { /// Last failure text for an offline account. String? errorOf(String serverId) => _errors[serverId]; - /// The Host tab's last Jellyfin pick, restored on the next start. + /// The last Jellyfin pick put up, restored in the Library on the next start. JellyfinMedia? get lastPick => _lastPick; /// Stable per install: Jellyfin keys devices and tokens by it. diff --git a/apps/desktop/lib/src/jellyfin/accounts_panel.dart b/apps/desktop/lib/src/jellyfin/accounts_panel.dart index 3523bd3..6277f16 100644 --- a/apps/desktop/lib/src/jellyfin/accounts_panel.dart +++ b/apps/desktop/lib/src/jellyfin/accounts_panel.dart @@ -74,7 +74,7 @@ class _JellyfinAccountsPanelState extends State { Text('Jellyfin', style: t.titleMedium), const SizedBox(height: 4), const Text( - 'Browse your libraries in the Host tab and put up movies and episodes. ' + 'Browse your libraries in the Library tab and put up movies and episodes. ' 'Sign in with Quick Connect or your password; several servers are fine.', style: TextStyle(color: Colors.grey), ), diff --git a/apps/desktop/lib/src/jellyfin/browser.dart b/apps/desktop/lib/src/jellyfin/browser.dart index 9edaa9b..56561d7 100644 --- a/apps/desktop/lib/src/jellyfin/browser.dart +++ b/apps/desktop/lib/src/jellyfin/browser.dart @@ -91,7 +91,7 @@ class _JellyfinBrowserState extends State { const [HomeRoute()]; /// Every navigation is saved per server (Hive), so the next browser - /// (Host tab, swap panel, next start) opens where this one was. + /// (the Library tab, next start) opens where this one was. void _remember() { final id = _account?.serverId; if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack))); diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 9c50ea7..f4f038b 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -66,7 +66,7 @@ class PlaybackController { /// Seeks from the video controls (seek bar) go here instead of to mpv. void Function(Duration position)? onUserSeek; - /// Latest shader message for the user (the session screen toasts it). + /// Latest shader message for the user (the session player toasts it). final shaderNotice = signal(null); /// How media_kit renders the current player's video (shaders need diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 2378782..7be6134 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -25,9 +25,10 @@ Future openInBrowser(String url) async { } } -class AboutPage extends StatelessWidget { +/// Name, version, update state and the source link (Settings → About). +class AboutPanel extends StatelessWidget { final UpdateController? updates; - const AboutPage({super.key, this.updates}); + const AboutPanel({super.key, this.updates}); Future _open(BuildContext context) async { final messenger = ScaffoldMessenger.maybeOf(context); @@ -40,11 +41,7 @@ class AboutPage extends StatelessWidget { @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; - return PageScroll( - maxWidth: 680, - children: [ - const PageTitle('About'), - Panel( + return Panel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -73,8 +70,6 @@ class AboutPage extends StatelessWidget { ), ], ), - ), - ], - ); + ); } } diff --git a/apps/desktop/lib/src/screens/home/connect_flow.dart b/apps/desktop/lib/src/screens/home/connect_flow.dart index a2b51a8..27fd503 100644 --- a/apps/desktop/lib/src/screens/home/connect_flow.dart +++ b/apps/desktop/lib/src/screens/home/connect_flow.dart @@ -1,12 +1,8 @@ -import 'dart:io'; - import 'package:flutter/foundation.dart'; -import 'package:sync_relay_core/core.dart' show HelloMedia; import '../../session/session_coordinator.dart'; import '../../state/session_state.dart'; -import '../../widgets/avatar_pipeline.dart'; -import '../../widgets/media_input.dart'; +import 'profile.dart'; /// What a card shows under its fields after a failed attempt. class CardError { @@ -15,7 +11,7 @@ class CardError { const CardError(this.title, this.message); } -/// Host/Join submit state for the two Home cards. One attempt at a time; +/// Host/Join submit state for the strip's two connect cards. One attempt at a time; /// [cancel] abandons it via [SessionState.cancelPending]. class ConnectFlow extends ChangeNotifier { ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance; @@ -27,7 +23,8 @@ class ConnectFlow extends ChangeNotifier { int _attempt = 0; bool _disposed = false; - /// Set when an attempt ends in a kick, so Home shows the removal dialog. + /// Set when an attempt ends in a kick, so the shell shows the removal + /// dialog. void Function(String reason)? onKicked; @override @@ -54,61 +51,38 @@ class ConnectFlow extends ChangeNotifier { } /// Validates and builds the params; throws [FormatException] with a - /// user-facing message. - static Future buildParams(ConnectMode mode, ConnectForm f) async { + /// user-facing message. [avatar] is the base64 picture (the profile's). + /// Hosting puts nothing up: what to watch is picked in the library. + static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) { final rawPort = mode == ConnectMode.host ? f.hostPort : f.port; final port = int.tryParse(rawPort.trim()); if (port == null || port <= 0 || port > 65535) { throw const FormatException('port must be 1–65535'); } if (f.name.trim().isEmpty) { - throw const FormatException('set your display name first (click your profile at the top)'); + throw const FormatException('set your display name first (click your avatar at the top left)'); } if (mode == ConnectMode.join && f.host.trim().isEmpty) { throw const FormatException('enter the server address'); } - String avatar = ''; - try { - avatar = await encodeAvatar(f.avatarPath); - } on FormatException catch (e) { - throw FormatException(e.message); - } catch (_) { - throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo'); - } - if (mode == ConnectMode.host && - f.mediaPath.isNotEmpty && - !await File(f.mediaPath).exists()) { - throw const FormatException('media file not found — check the path or pick again with Browse'); - } return ConnectParams( host: f.host.trim(), port: port, secret: mode == ConnectMode.host ? f.hostSecret : f.secret, name: f.name.trim(), avatar: avatar, - mediaPath: mode == ConnectMode.host ? f.mediaPath : '', - linkUrl: mode == ConnectMode.host ? f.linkUrl : '', ); } /// [edit] fills the card's fields into a copy of the last form, so the - /// other card's saved values survive. [media] (host only) is put up - /// instead of the form's link or file (a Jellyfin ref; the pick itself is - /// remembered in the Jellyfin box, not the form). - Future submit(ConnectMode mode, void Function(ConnectForm f) edit, - {HelloMedia? media}) async { + /// other card's saved values survive. + Future submit(ConnectMode mode, void Function(ConnectForm f) edit) async { if (busy != null) return; final myAttempt = ++_attempt; final form = _session.lastForm.copy(); edit(form); form.name = form.name.trim(); form.host = form.host.trim(); - if (mode == ConnectMode.host) { - final parsed = parseMediaInput(form.linkUrl.isNotEmpty ? form.linkUrl : form.mediaPath); - form - ..mediaPath = parsed.mediaPath - ..linkUrl = parsed.linkUrl; - } busy = mode; errors[mode] = null; notices[mode] = null; @@ -116,8 +90,9 @@ class ConnectFlow extends ChangeNotifier { ConnectParams params; try { - params = await buildParams(mode, form); - if (media != null && mode == ConnectMode.host) params = params.withMedia(media); + final profile = Profile.instance; + await profile.ensureLoaded(); + params = buildParams(mode, form, avatar: profile.avatarBase64()); } catch (e) { if (myAttempt != _attempt) return; busy = null; diff --git a/apps/desktop/lib/src/screens/home/home_screen.dart b/apps/desktop/lib/src/screens/home/home_screen.dart deleted file mode 100644 index 2691dc0..0000000 --- a/apps/desktop/lib/src/screens/home/home_screen.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:signals/signals_flutter.dart'; - -import '../../jellyfin/accounts.dart'; -import '../../state/session_state.dart'; -import '../../state/settings.dart'; -import '../../update/update_controller.dart'; -import 'about_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 'top_bar.dart'; -import 'update_indicator.dart'; - -/// 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: HomeShell(windowControls: true)); - } -} - -/// [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; - final bool windowControls; - final HealthGetter healthGet; - /// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance]. - final JellyfinAccounts? jellyfin; - const HomeShell({ - super.key, - this.updates, - this.autoUpdateCheck = true, - this.windowControls = false, - this.healthGet = defaultHealthGet, - this.jellyfin, - }); - - @override - State createState() => _HomeShellState(); -} - -class _HomeShellState extends State { - HomeView _view = HomeView.join; - final _flow = ConnectFlow(); - late final _join = JoinFields(SessionState.instance.lastForm); - late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance; - late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin); - late final void Function() _stopKickWatch; - late final void Function() _stopCrashWatch; - bool _dialogOpen = false; - String? _lastKickShown; - - @override - void initState() { - super.initState(); - Profile.instance.ensureLoaded(); - // Reads linked servers and checks their tokens in the background. - _jellyfin.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 { - final s = AppSettings.instance; - await s.ensureLoaded(); - 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; - // 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 => IntegrationsPage(jellyfin: _jellyfin), - 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), - ), - ], - ), - ), - ); - } -} diff --git a/apps/desktop/lib/src/screens/home/host_tab.dart b/apps/desktop/lib/src/screens/home/host_tab.dart deleted file mode 100644 index 14321a1..0000000 --- a/apps/desktop/lib/src/screens/home/host_tab.dart +++ /dev/null @@ -1,392 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:signals/signals_flutter.dart'; -import 'package:sync_relay_core/core.dart' show HelloMedia; - -import '../../jellyfin/accounts.dart'; -import '../../jellyfin/session_media.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, {JellyfinAccounts? jellyfin}) - : jellyfin = jellyfin ?? JellyfinAccounts.instance, - port = TextEditingController(text: f.hostPort), - secret = TextEditingController(text: f.hostSecret), - media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) { - // The last Jellyfin pick lives in the Jellyfin box (the connect form - // only knows links and files); restore it once that box is read. - final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty; - this.jellyfin.ensureLoaded().then((_) { - final pick = this.jellyfin.lastPick; - if (_disposed || pick == null || media.jellyfin != null) return; - if (this.jellyfin.account(pick.serverId) == null) return; - if (noMedia) { - media.pickJellyfin(pick); - } else { - final source = media.source; - media.pickJellyfin(pick); - media.source = source; - } - }); - } - final JellyfinAccounts jellyfin; - final TextEditingController port; - final TextEditingController secret; - final MediaPickerController media; - /// Jellyfin browser enlarged: the server column shrinks to a bar. - final browserExpanded = ValueNotifier(false); - bool _disposed = false; - - void dispose() { - _disposed = true; - port.dispose(); - secret.dispose(); - media.dispose(); - browserExpanded.dispose(); - } -} - -/// What hosting a picked Jellyfin item sends: its ref (no URL, no token). -/// Throws [FormatException] with a user-facing message when the server is -/// not linked any more or its sign-in expired. -HelloMedia hostJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) { - if (accounts.account(media.serverId) == null) { - throw const FormatException('that Jellyfin server is not linked any more — pick again'); - } - if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) { - throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations'); - } - return mediaForPick(media); -} - -/// 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() { - if (flow.busy != null) return; - final media = fields.media.selection; - HelloMedia? jellyfinMedia; - if (media is JellyfinMedia) { - try { - jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media); - } on FormatException catch (e) { - flow.showError(ConnectMode.host, CardError('Check the form', e.message)); - return; - } - fields.jellyfin.rememberPick(media); - } - flow.submit( - ConnectMode.host, - (f) => f - ..hostPort = fields.port.text.trim() - ..hostSecret = fields.secret.text - ..mediaPath = media is FileMedia ? media.path : '' - ..linkUrl = media is LinkMedia ? media.url : '', - media: jellyfinMedia, - ); - } - - @override - Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; - return ListenableBuilder( - listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]), - builder: (context, _) { - final busy = flow.busy == ConnectMode.host; - final jellyfin = fields.media.source == MediaKind.jellyfin; - final expanded = jellyfin && fields.browserExpanded.value; - Widget picker({required bool fill}) { - final p = MediaPicker( - controller: fields.media, - enabled: !busy, - autofocus: true, - onSubmit: _host, - onOpenIntegrations: onOpenIntegrations, - jellyfin: fields.jellyfin, - expanded: expanded, - onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value, - ); - return Column( - key: const ValueKey('host-picker'), - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('What to watch', style: t.titleMedium), - const SizedBox(height: 12), - fill ? Expanded(child: p) : p, - ], - ); - } - - final column = _ServerColumn(flow: flow, fields: fields, onHost: _host); - return LayoutBuilder(builder: (context, box) { - final stacked = box.maxWidth < hostStackBelow; - // Jellyfin browser: fills the height (lazy grids need a bounded - // viewport); the form sits beside it or, enlarged, in a bar below. - if (expanded) { - return Padding( - padding: kPagePadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: picker(fill: true)), - const SizedBox(height: 12), - _HostBar(flow: flow, fields: fields, onHost: _host), - ], - ), - ); - } - if (jellyfin && !stacked) { - return Padding( - padding: kPagePadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: picker(fill: true)), - const SizedBox(width: 24), - SizedBox( - width: _columnWidth, - child: SingleChildScrollView(child: column), - ), - ], - ), - ); - } - return SingleChildScrollView( - padding: kPagePadding, - child: stacked - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - picker(fill: false), - 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(fill: false)), - 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), - ], - ), - ); - } - -} - -String mediaSummary(PickedMedia? s) => - s == null ? 'No media selected (optional).' : 'Playing: ${s.label}'; - -/// Enlarged-browser mode: what is picked, port, password and Host in one -/// row under the browser. -class _HostBar extends StatelessWidget { - final ConnectFlow flow; - final HostFields fields; - final VoidCallback onHost; - const _HostBar({required this.flow, required this.fields, required this.onHost}); - - @override - Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; - return Panel( - key: const ValueKey('host-bar'), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Text(mediaSummary(fields.media.selection), - maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium), - ), - const SizedBox(width: 12), - AbsorbPointer( - absorbing: flow.busy == ConnectMode.host, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 100, - child: TextField( - controller: fields.port, - decoration: fieldDecoration('Port'), - keyboardType: TextInputType.number, - onChanged: (_) => flow.clearError(ConnectMode.host), - onSubmitted: (_) => onHost(), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 200, - child: TextField( - controller: fields.secret, - obscureText: true, - decoration: fieldDecoration('Password', hint: 'none = open'), - onSubmitted: (_) => onHost(), - ), - ), - ], - ), - ), - const SizedBox(width: 12), - ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost), - ], - ), - ConnectFeedback(flow: flow, mode: ConnectMode.host), - ], - ), - ); - } -} - -/// 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/integrations_page.dart b/apps/desktop/lib/src/screens/home/integrations_page.dart deleted file mode 100644 index 03e0ae9..0000000 --- a/apps/desktop/lib/src/screens/home/integrations_page.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../jellyfin/accounts.dart'; -import '../../jellyfin/accounts_panel.dart'; -import 'ui.dart'; - -class IntegrationsPage extends StatelessWidget { - /// Defaults to [JellyfinAccounts.instance]. - final JellyfinAccounts? jellyfin; - const IntegrationsPage({super.key, this.jellyfin}); - - @override - Widget build(BuildContext context) { - return PageScroll( - maxWidth: 820, - children: [ - const PageTitle('Integrations', - subtitle: 'Connect media libraries to pick what to watch.'), - JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance), - ], - ); - } -} diff --git a/apps/desktop/lib/src/screens/home/join_tab.dart b/apps/desktop/lib/src/screens/home/join_tab.dart deleted file mode 100644 index f2f5d8d..0000000 --- a/apps/desktop/lib/src/screens/home/join_tab.dart +++ /dev/null @@ -1,388 +0,0 @@ -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 index ad0fa99..7e0a259 100644 --- a/apps/desktop/lib/src/screens/home/media_picker.dart +++ b/apps/desktop/lib/src/screens/home/media_picker.dart @@ -15,8 +15,8 @@ const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts']; enum MediaKind { jellyfin, link, file } -/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab, -/// later the session's swap panel) so it survives rebuilds. +/// State of a [MediaPicker], owned by whoever hosts it (the shell, for the +/// Library tab) so it survives rebuilds and tab switches. class MediaPickerController extends ChangeNotifier { MediaPickerController( {String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source}) diff --git a/apps/desktop/lib/src/screens/home/profile.dart b/apps/desktop/lib/src/screens/home/profile.dart index 8588a2a..53c68f8 100644 --- a/apps/desktop/lib/src/screens/home/profile.dart +++ b/apps/desktop/lib/src/screens/home/profile.dart @@ -1,285 +1,131 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; +import '../../state/connect_prefs.dart'; import '../../state/session_state.dart'; import '../../widgets/avatar_pipeline.dart'; -import 'ui.dart'; -/// Display name + avatar, shared by the Home profile row and Settings. -/// Backed by [SessionState.lastForm]; every change is persisted. The -/// avatar goes through [encodeAvatar] (the same shrink the server gets) -/// before its path is stored, and the encoded bytes are the preview. +/// Colours offered in the profile card; the first is the default. +const profileColors = [ + Colors.deepPurple, + Colors.indigo, + Colors.teal, + Colors.pink, + Colors.orange, + Colors.blueGrey, +]; + +const _keyPicture = 'avatarPng'; +const _keyColor = 'profileColor'; + +/// Sends a mid-session name/picture change (null = unchanged, empty +/// avatar = removed). Swappable so tests can watch it. +typedef ProfilePush = void Function({String? name, String? avatar}); + +/// The one place a profile change reaches the server. +void pushProfileToSession({String? name, String? avatar}) { + final session = SessionState.instance; + if (session.route.peek() != AppRoute.session) return; + session.coordinator.updateProfile(name: name, avatar: avatar); +} + +/// Display name, picture and colour of this app's user. Backed by +/// [SessionState.lastForm] (name) and the connect-form box (the cropped +/// 256 px PNG and the colour); every change is persisted, and while in a +/// server it is pushed through [push] so everyone sees it at once. class Profile { static final Profile instance = Profile._(); Profile._(); final name = signal(''); - final preview = signal(null); + /// The cropped PNG; null shows the initial on [color]. + final picture = signal(null); + final color = signal(profileColors.first); final error = signal(null); - final picking = signal(false); + ProfilePush push = pushProfileToSession; bool _loaded = false; + Timer? _nameTimer; - void ensureLoaded() { + /// Reads the stored profile once (the app shell starts it). Old installs + /// stored a picture path: it is shrunk through [encodeAvatar] once and + /// kept as bytes. A caller during the first read does not wait for it + /// (no shared Future: tests run each in its own zone). + Future ensureLoaded() async { if (_loaded) return; _loaded = true; - final f = SessionState.instance.lastForm; - name.value = f.name; - _refreshPreview(f.avatarPath); + await _load(); } - Future _refreshPreview(String path) async { + Future _load() async { + final form = SessionState.instance.lastForm; + name.value = form.name; try { - final b64 = await encodeAvatar(path); - preview.value = b64.isEmpty ? null : base64Decode(b64); - } catch (_) { - preview.value = null; + final box = connectFormBoxIfOpen() ?? await openConnectFormBox(); + final png = box.get(_keyPicture); + final c = box.get(_keyColor); + if (c is int) color.value = Color(c); + if (png is Uint8List && png.isNotEmpty) { + picture.value = png; + } else if (form.avatarPath.isNotEmpty) { + final b64 = await encodeAvatar(form.avatarPath); + if (b64.isNotEmpty) picture.value = base64Decode(b64); + form.avatarPath = ''; + await _save(); + } + } catch (e) { + // No store (tests) or an unreadable old picture: start without. + debugPrint('profile load: $e'); } } + /// The picture as sent in hello and profile updates; '' when none. + String avatarBase64() { + final png = picture.peek(); + return png == null ? '' : avatarWireBase64(png); + } + void setName(String value) { - name.value = value.trim(); - SessionState.instance.lastForm.name = value.trim(); - _save(); + final v = value.trim(); + if (v.isEmpty || v == name.peek()) return; + name.value = v; + SessionState.instance.lastForm.name = v; + unawaited(_save()); + // Typing renames once it settles, not on every key. + _nameTimer?.cancel(); + _nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek())); } - Future pickAvatar() async { - if (picking.peek()) return; - picking.value = true; + Future setPicture(Uint8List? png) async { + picture.value = png; error.value = null; + await _save(); try { - final List files; - try { - files = await FilePicker.pickFiles( - dialogTitle: 'Pick avatar image', - type: FileType.custom, - allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'], - ); - } catch (_) { - error.value = 'file dialog unavailable — try again'; - return; - } - final path = files.isEmpty ? null : files.first.path; - if (path == null) return; - final String b64; - try { - b64 = await encodeAvatar(path); - } on FormatException catch (e) { - error.value = e.message; - return; - } catch (_) { - error.value = 'could not read that image — pick a PNG, JPG or WebP photo'; - return; - } - SessionState.instance.lastForm.avatarPath = path; - preview.value = b64.isEmpty ? null : base64Decode(b64); - await _save(); - } finally { - picking.value = false; + push(avatar: png == null ? '' : avatarWireBase64(png)); + } on FormatException catch (e) { + error.value = e.message; } } - Future clearAvatar() async { - SessionState.instance.lastForm.avatarPath = ''; - preview.value = null; - error.value = null; + Future setColor(Color c) async { + color.value = c; await _save(); } Future _save() async { try { await SessionState.instance.saveForm(); + final box = connectFormBoxIfOpen() ?? await openConnectFormBox(); + final png = picture.peek(); + await box.putAll({ + _keyPicture: png ?? Uint8List(0), + _keyColor: color.peek().toARGB32(), + }); } catch (_) { error.value = 'could not save profile — try again'; } } } - -/// Avatar circle with the initial as fallback. -class ProfileAvatar extends StatelessWidget { - final double radius; - const ProfileAvatar({super.key, this.radius = 20}); - - @override - Widget build(BuildContext context) { - final p = Profile.instance; - return SignalBuilder(builder: (context) { - final img = p.preview.value; - final n = p.name.value; - return CircleAvatar( - radius: radius, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - backgroundImage: img != null ? MemoryImage(img) : null, - child: img != null - ? null - : p.picking.value - ? SizedBox( - width: radius * 0.8, - height: radius * 0.8, - child: const CircularProgressIndicator(strokeWidth: 2)) - : Text(n.isEmpty ? '?' : n[0].toUpperCase(), - style: TextStyle(fontSize: radius * 0.8)), - ); - }); - } -} - -/// Avatar (tap to change) + name field. Used inline in Settings and in -/// the Home profile dialog. -class ProfileEditor extends StatefulWidget { - final bool autofocus; - final VoidCallback? onSubmitted; - const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted}); - - @override - State createState() => _ProfileEditorState(); -} - -class _ProfileEditorState extends State { - late final _name = TextEditingController(text: Profile.instance.name.peek()); - - @override - void dispose() { - _name.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final p = Profile.instance; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Tooltip( - message: 'Change avatar', - child: InkWell( - customBorder: const CircleBorder(), - onTap: p.pickAvatar, - child: const ProfileAvatar(radius: 36), - ), - ), - const SizedBox(width: kGap), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _name, - autofocus: widget.autofocus, - decoration: fieldDecoration('Display name'), - onChanged: p.setName, - onSubmitted: (_) => widget.onSubmitted?.call(), - ), - const SizedBox(height: 4), - SignalBuilder(builder: (context) { - final err = p.error.value; - return Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - TextButton( - onPressed: p.pickAvatar, - child: const Text('Change avatar')), - if (p.preview.value != null) - TextButton( - onPressed: p.clearAvatar, - child: const Text('Remove avatar')), - if (err != null) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text(err, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.error)), - ), - ], - ); - }), - ], - ), - ), - ], - ); - } -} - -Future showProfileDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)), - title: const Text('Profile'), - content: SizedBox( - width: 380, - child: ProfileEditor( - autofocus: true, - onSubmitted: () => Navigator.pop(context), - ), - ), - actions: [ - FilledButton( - onPressed: () => Navigator.pop(context), child: const Text('Done')), - ], - ), - ); -} - -/// Home's top-left: avatar + name, click to edit. -class ProfileChip extends StatelessWidget { - /// 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) { - final p = Profile.instance; - return Tooltip( - message: 'Edit profile', - child: InkWell( - borderRadius: BorderRadius.circular(kRadius), - onTap: () => showProfileDialog(context), - 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: [ - 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/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 753bc2a..c27116d 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -6,57 +6,31 @@ import '../../playback/playback_controller.dart'; import '../../shaders/shader_presets.dart'; import '../../state/settings.dart'; import '../../update/update_controller.dart'; -import 'profile.dart'; import 'ui.dart'; import 'update_indicator.dart'; -/// In-session keys, as handled by the session screen's global key handler. +/// Player keys, as handled by the session player's key handler (while +/// the player fills the window). const sessionShortcuts = [ ('Space', 'Toggle ready'), ('← / →', 'Seek back / forward 10 s'), ('Shift+→', 'Skip 1:30 (for everyone)'), ('PgUp / PgDn', 'Previous / next chapter'), ('F', 'Toggle fullscreen'), - ('Tab', 'Open / close the people & chat panel'), + ('Tab', 'Open / close chat & activity'), ('Ctrl+R', 'Reload the video'), ('> or Shift+N', 'Put up the next Jellyfin episode'), ('< or Shift+P', 'Put up the previous Jellyfin episode'), ('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'), ('Ctrl+0', 'Shaders off'), - ('Esc', 'Close panel or fullscreen, else ask to leave'), + ('Esc', 'Close the panel or fullscreen, else ask to leave'), ]; -class SettingsPage extends StatelessWidget { - final UpdateController? updates; - const SettingsPage({super.key, this.updates}); - - @override - Widget build(BuildContext context) { - return PageScroll( - maxWidth: 680, - children: [ - const PageTitle('Settings'), - const _Section(title: 'Profile', child: ProfileEditor()), - const _Section( - title: 'Player', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [_HwdecSetting(), SizedBox(height: 12), _MpvControlsSetting()], - ), - ), - const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()), - _Section(title: 'Updates', child: _UpdateSettings(controller: updates)), - const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()), - const _Section(title: 'Video quality & shaders', child: _ShaderSettings()), - ], - ); - } -} - -class _Section extends StatelessWidget { +/// One titled panel of the Settings tab. +class SettingsPanel extends StatelessWidget { final String title; final Widget child; - const _Section({required this.title, required this.child}); + const SettingsPanel({super.key, required this.title, required this.child}); @override Widget build(BuildContext context) { @@ -72,14 +46,14 @@ class _Section extends StatelessWidget { } } -class _HwdecSetting extends StatefulWidget { - const _HwdecSetting(); +class HwdecSetting extends StatefulWidget { + const HwdecSetting({super.key}); @override - State<_HwdecSetting> createState() => _HwdecSettingState(); + State createState() => _HwdecSettingState(); } -class _HwdecSettingState extends State<_HwdecSetting> { +class _HwdecSettingState extends State { final _s = AppSettings.instance; late final _custom = TextEditingController(text: _s.hwdecCustom.peek()); @@ -130,8 +104,8 @@ class _HwdecSettingState extends State<_HwdecSetting> { } } -class _ShaderSettings extends StatelessWidget { - const _ShaderSettings(); +class ShaderSettings extends StatelessWidget { + const ShaderSettings({super.key}); Future _addFiles() async { final List files; @@ -249,9 +223,9 @@ class _ShaderSettings extends StatelessWidget { } } -class _UpdateSettings extends StatelessWidget { +class UpdateSettings extends StatelessWidget { final UpdateController? controller; - const _UpdateSettings({this.controller}); + const UpdateSettings({super.key, this.controller}); @override Widget build(BuildContext context) { @@ -301,8 +275,8 @@ class _UpdateSettings extends StatelessWidget { } } -class _MpvControlsSetting extends StatelessWidget { - const _MpvControlsSetting(); +class MpvControlsSetting extends StatelessWidget { + const MpvControlsSetting({super.key}); @override Widget build(BuildContext context) { @@ -321,8 +295,8 @@ class _MpvControlsSetting extends StatelessWidget { } } -class _AutoAdvanceSetting extends StatelessWidget { - const _AutoAdvanceSetting(); +class AutoAdvanceSetting extends StatelessWidget { + const AutoAdvanceSetting({super.key}); @override Widget build(BuildContext context) { @@ -341,8 +315,8 @@ class _AutoAdvanceSetting extends StatelessWidget { } } -class _ShortcutList extends StatelessWidget { - const _ShortcutList(); +class ShortcutList extends StatelessWidget { + const ShortcutList({super.key}); @override Widget build(BuildContext context) { diff --git a/apps/desktop/lib/src/screens/home/top_bar.dart b/apps/desktop/lib/src/screens/home/top_bar.dart deleted file mode 100644 index 94fed35..0000000 --- a/apps/desktop/lib/src/screens/home/top_bar.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../../update/update_controller.dart'; -import '../../widgets/window_buttons.dart'; -import '../demo/shell_demo.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), - if (kDebugMode) - IconButton( - tooltip: 'Layout demo (debug builds only)', - icon: const Icon(Icons.bug_report_outlined, size: 20, color: Colors.amber), - onPressed: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const ShellDemo())), - ), - 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 4b8c147..863c459 100644 --- a/apps/desktop/lib/src/screens/home/ui.dart +++ b/apps/desktop/lib/src/screens/home/ui.dart @@ -7,9 +7,8 @@ const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project'; const kRadius = 12.0; const kGap = 16.0; -const kPagePadding = EdgeInsets.fromLTRB(28, 20, 28, 28); -/// Rounded panel used by every Home/Settings section. +/// Rounded panel used by every Settings section. class Panel extends StatelessWidget { final Widget child; final EdgeInsetsGeometry padding; @@ -38,33 +37,6 @@ class Panel extends StatelessWidget { } } -/// Page heading + optional subtitle, same spacing everywhere. -class PageTitle extends StatelessWidget { - final String title; - final String? subtitle; - const PageTitle(this.title, {super.key, this.subtitle}); - - @override - Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; - return Padding( - padding: const EdgeInsets.only(bottom: kGap), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: t.headlineSmall), - if (subtitle != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text(subtitle!, - style: t.bodyMedium?.copyWith(color: Colors.grey)), - ), - ], - ), - ); - } -} - /// Small grey heading inside a panel. class SectionLabel extends StatelessWidget { final String text; @@ -87,7 +59,7 @@ class SectionLabel extends StatelessWidget { } } -/// Text field style shared by the connect cards. +/// Outlined text field style (settings, Jellyfin sign-in). InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) => InputDecoration( labelText: label, @@ -97,31 +69,6 @@ InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) => border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), ); -/// Single-item scrollable page body, width-capped so it doesn't sprawl -/// on wide windows. -class PageScroll extends StatelessWidget { - final List children; - final double maxWidth; - const PageScroll({super.key, required this.children, this.maxWidth = 920}); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: kPagePadding, - child: Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ), - ); - } -} - /// Widest a single text field gets, however wide the window. const kFieldMaxWidth = 720.0; diff --git a/apps/desktop/lib/src/screens/home/update_indicator.dart b/apps/desktop/lib/src/screens/home/update_indicator.dart index 49d6018..04058fe 100644 --- a/apps/desktop/lib/src/screens/home/update_indicator.dart +++ b/apps/desktop/lib/src/screens/home/update_indicator.dart @@ -4,7 +4,7 @@ import '../../update/update_controller.dart'; import '../../widgets/update_banner.dart'; import 'ui.dart'; -/// Compact "Update vX" pill for Home's top-right; hidden until there is +/// Compact "Update vX" pill for the party strip; hidden until there is /// something to install. Click opens the full [UpdateBanner]. class UpdateIndicator extends StatelessWidget { final UpdateController? controller; diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index d93839d..5bdc512 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -4,7 +4,7 @@ import '../../jellyfin/session_media.dart'; import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; import 'player_controls.dart' show FloatingPill; -// Jellyfin pieces of the session screen. Plain widgets fed by the +// Jellyfin pieces of the session player. Plain widgets fed by the // coordinator's signals, so tests can pump them without the player. /// Previous / next episode in the video controls. Nothing when there is diff --git a/apps/desktop/lib/src/screens/session/swap_panel.dart b/apps/desktop/lib/src/screens/session/swap_panel.dart index 1ebb3ba..c726a7c 100644 --- a/apps/desktop/lib/src/screens/session/swap_panel.dart +++ b/apps/desktop/lib/src/screens/session/swap_panel.dart @@ -4,28 +4,34 @@ import '../../jellyfin/accounts.dart'; import '../../jellyfin/browse_scope.dart' show BrowseStart; import '../home/media_picker.dart'; -/// "Change video" in a session: the same [MediaPicker] as Home's Host tab -/// (Jellyfin browser, Link, File) and a button that puts the pick up for -/// everyone. A file streams from this computer: the host's app serves it, -/// anyone else's goes through the server's relay. [onSwap] returns an -/// error text, or null when sent. +/// The Library tab's body: the [MediaPicker] (Jellyfin browser, Link, +/// File) and a button that puts the pick up for everyone. A file streams from this computer: the host's app +/// serves it, anyone else's goes through the server's relay. [onSwap] +/// returns an error text, or null when sent. class SwapPanel extends StatefulWidget { final MediaPickerController controller; final JellyfinAccounts accounts; final bool isHost; final BrowseStart? jellyfinStart; final Future Function(PickedMedia media) onSwap; - final VoidCallback onClose; + + /// After a pick went up. + final VoidCallback? onDone; final VoidCallback? onOpenIntegrations; + + /// Set when nothing can go up (offline): shown instead of the pick, and + /// the button is off. + final String? disabledHint; const SwapPanel({ super.key, required this.controller, required this.accounts, required this.isHost, required this.onSwap, - required this.onClose, + this.onDone, this.jellyfinStart, this.onOpenIntegrations, + this.disabledHint, }); @override @@ -38,7 +44,7 @@ class _SwapPanelState extends State { Future _swap() async { final media = widget.controller.selection; - if (media == null || _busy) return; + if (media == null || _busy || widget.disabledHint != null) return; setState(() { _busy = true; _error = null; @@ -54,79 +60,79 @@ class _SwapPanelState extends State { _busy = false; _error = error; }); - if (error == null) widget.onClose(); + if (error == null) widget.onDone?.call(); } @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; - return Material( + final hint = widget.disabledHint; + return Padding( key: const ValueKey('swap-panel'), - elevation: 12, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 12, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded(child: Text('Change video', style: t.titleLarge)), - IconButton( - tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose), - ], - ), - const SizedBox(height: 8), - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: MediaPicker( - controller: widget.controller, - jellyfin: widget.accounts, - enabled: !_busy, - linkRelayChoice: true, - fileHelp: widget.isHost - ? 'Friends stream it from your computer.' - : 'Streams from your computer through the server. Keep the app open while it plays.', - jellyfinStart: widget.jellyfinStart, - onOpenIntegrations: widget.onOpenIntegrations, - onSubmit: _swap, - ), + padding: const EdgeInsets.fromLTRB(20, 16, 12, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: MediaPicker( + controller: widget.controller, + jellyfin: widget.accounts, + enabled: !_busy, + linkRelayChoice: true, + fileHelp: widget.isHost + ? 'Friends stream it from your computer.' + : 'Streams from your computer through the server. Keep the app open while it plays.', + jellyfinStart: widget.jellyfinStart, + onOpenIntegrations: widget.onOpenIntegrations, + onSubmit: _swap, ), ), - const SizedBox(height: 12), - ListenableBuilder( - listenable: widget.controller, - builder: (context, _) { - final sel = widget.controller.selection; - return Row( - children: [ - Expanded( - child: Text( - _error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: t.bodyMedium?.copyWith( - color: _error != null ? Theme.of(context).colorScheme.error : null), + ), + const SizedBox(height: 12), + ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final sel = widget.controller.selection; + return Row( + children: [ + Expanded( + child: Text( + _error ?? + hint ?? + (sel == null + ? 'Pick something to watch' + : 'Put up: ${sel.label}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: t.bodyMedium?.copyWith( + color: _error != null + ? Theme.of(context).colorScheme.error + : null, ), ), - const SizedBox(width: 12), - FilledButton.icon( - key: const ValueKey('swap-go'), - onPressed: sel == null || _busy ? null : _swap, - icon: _busy - ? const SizedBox( - width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.swap_horiz), - label: const Text('Put it up for everyone'), - ), - ], - ); - }, - ), - ], - ), + ), + const SizedBox(width: 12), + FilledButton.icon( + key: const ValueKey('swap-go'), + onPressed: sel == null || _busy || hint != null + ? null + : _swap, + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.swap_horiz), + label: const Text('Put it up for everyone'), + ), + ], + ); + }, + ), + ], ), ); } diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart deleted file mode 100644 index 84b05ca..0000000 --- a/apps/desktop/lib/src/screens/session_screen.dart +++ /dev/null @@ -1,1491 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:signals/signals_flutter.dart'; -import 'package:sync_relay_core/core.dart'; - -import '../controls/uosc/buttons.dart'; -import '../controls/uosc/controls.dart'; -import '../controls/uosc/timeline.dart'; -import '../playback/media_marks.dart' show SkipOffer; - -import '../jellyfin/browse_scope.dart' show BrowseStart; -import '../jellyfin/session_media.dart'; -import '../session/health.dart'; -import '../session/session_coordinator.dart'; -import '../shaders/shader_menu.dart'; -import '../shaders/shader_presets.dart'; -import '../state/settings.dart'; -import '../state/session_state.dart'; -import '../update/updater.dart'; -import '../widgets/dialogs.dart'; -import '../widgets/member_avatar.dart'; -import '../widgets/shown_frame_probe.dart'; -import '../widgets/text_focus.dart'; -import '../widgets/window_buttons.dart'; -import '../widgets/window_drag_layer.dart'; -import 'home/integrations_page.dart'; -import 'home/media_picker.dart'; -import 'session/jellyfin_widgets.dart'; -import 'session/player_controls.dart'; -import 'session/swap_panel.dart'; - -class SessionScreen extends StatefulWidget { - const SessionScreen({super.key}); - - @override - State createState() => _SessionScreenState(); -} - -class _SessionScreenState extends State { - // One key per player generation: a reloaded player needs a fresh - // `Video` (it never swaps controllers in place). - var _videoGen = -1; - var _videoKey = GlobalKey(); - bool _refullscreen = false; - final _frameProbe = ShownFrameProbe(); - final _sheetOpen = signal(false); - final _confirmLeave = signal(false); - final _isFullscreen = signal(false); - DateTime? _lastKeyAt; - final _chat = TextEditingController(); - final _chatFocus = FocusNode(); - // "Change video": the shared media picker over the video. - final _swapOpen = signal(false); - MediaPickerController? _swapPicker; - bool _integrationsOpen = false; - late final void Function() _stopKickWatch; - late final void Function() _stopSheetWatch; - late final void Function() _stopPlayerWatch; - late final void Function() _stopShaderWatch; - late final FocusNode _noFocus; - bool _shaderMenuOpen = false; - // Own toast ids, negative: the coordinator counts up from 1. - int _toastSeq = 0; - int _shaderNoticeSeen = 0; - - @override - void initState() { - super.initState(); - final session = SessionState.instance; - _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); - session.coordinator.jellyfin.ensureLoaded(); - session.coordinator.playback.shownFrame = _frameProbe.sample; - HardwareKeyboard.instance.addHandler(_globalKey); - _stopKickWatch = effect(() { - if (session.coordinator.kickReason.value != null) { - session.route.value = AppRoute.home; - } - }); - _stopSheetWatch = effect(() { - if (!_sheetOpen.value) { - FocusManager.instance.primaryFocus?.unfocus(); - } - }); - // Shader messages (preset changes, load failures) become toasts. - _stopShaderWatch = effect(() { - final n = session.coordinator.playback.shaderNotice.value; - if (n == null || n.id == _shaderNoticeSeen) return; - _shaderNoticeSeen = n.id; - _toast(n.text); - }); - // The fullscreen route holds its own `Video` of the old player: leave - // it before the player goes away, re-enter once the new one is up. - _stopPlayerWatch = effect(() { - final alive = session.coordinator.playback.alive.value; - if (!alive && _isFullscreen.peek()) { - _refullscreen = true; - if (mounted) Navigator.of(context, rootNavigator: true).maybePop(); - } else if (alive && _refullscreen) { - _refullscreen = false; - // Entering before the new output's first frame leaves the - // fullscreen copy blank for good (seen in a real run). - () async { - try { - await session.coordinator.playback.video.waitUntilFirstFrameRendered - .timeout(const Duration(seconds: 10)); - } catch (_) {} - await Future.delayed(const Duration(milliseconds: 100)); - if (mounted && !_isFullscreen.peek() && session.coordinator.playback.alive.peek()) { - _videoKey.currentState?.enterFullscreen(); - } - }(); - } - }); - } - - void _reloadVideo() { - final coord = SessionState.instance.coordinator; - coord.markInput(); - coord.reloadVideo(); - } - - void _toast(String text) { - final coord = SessionState.instance.coordinator; - coord.toasts.value = [ - ...coord.toasts.peek(), - ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()), - ]; - } - - /// Menu, Ctrl+0..6: saved as the choice (also used on the next join) - /// and applied live. A null quality keeps the saved one. - void _pickShader(ShaderPreset preset, ShaderQuality? quality) { - final s = AppSettings.instance; - if (quality != null) s.setShaderQuality(quality); - s.setShaderPreset(preset); - final coord = SessionState.instance.coordinator; - coord.markInput(); - coord.playback.applyShaders(announce: true); - } - - void _toggleFullscreen() { - if (_isFullscreen.peek()) { - if (mounted) { - Navigator.of(context, rootNavigator: true).maybePop(); - } - } else { - _videoKey.currentState?.enterFullscreen(); - } - } - - @override - void dispose() { - HardwareKeyboard.instance.removeHandler(_globalKey); - final playback = SessionState.instance.coordinator.playback; - if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; - _stopKickWatch(); - _stopSheetWatch(); - _stopPlayerWatch(); - _stopShaderWatch(); - _chat.dispose(); - _chatFocus.dispose(); - _swapPicker?.dispose(); - _noFocus.dispose(); - super.dispose(); - } - - /// Opens the swap panel: the browser starts on the series of the - /// episode playing (season selected, episode highlighted), else where it - /// was last. - void _openSwap() { - final coord = SessionState.instance.coordinator; - coord.markInput(); - final ref = coord.currentRef.peek(); - final accounts = coord.jellyfin; - final pick = ref == null ? null : pickForRef(accounts, ref); - final plan = coord.jellyfinPlan.peek(); - _swapPicker?.dispose(); - _swapPicker = MediaPickerController( - jellyfin: pick, - source: pick != null || (accounts.loaded && !accounts.isEmpty) - ? MediaKind.jellyfin - : MediaKind.link, - ); - _swapStart = ref != null && plan is OwnPlay && plan.item != null - ? BrowseStart.seriesOf(ref.serverId, plan.item!) - : null; - _sheetOpen.value = false; - _swapOpen.value = true; - } - - BrowseStart? _swapStart; - - void _closeSwap() { - _swapOpen.value = false; - FocusManager.instance.primaryFocus?.unfocus(); - } - - /// Integrations over the session (link an account for a blocked item). - Future _openIntegrations() async { - if (_integrationsOpen) return; - _integrationsOpen = true; - try { - await Navigator.of(context, rootNavigator: true).push(MaterialPageRoute( - builder: (_) => Scaffold( - appBar: AppBar(title: const Text('Integrations')), - body: IntegrationsPage(jellyfin: SessionState.instance.coordinator.jellyfin), - ), - )); - } finally { - _integrationsOpen = false; - } - } - - bool _globalKey(KeyEvent event) { - if (event is! KeyDownEvent) return false; - if (SessionState.instance.route.peek() != AppRoute.session) return false; - if (_integrationsOpen) return false; - if (_swapOpen.peek()) { - // The picker owns the keyboard; Esc outside a field closes it. - if (event.logicalKey == LogicalKeyboardKey.escape && !textInputFocused()) { - _closeSwap(); - return true; - } - return false; - } - if (textInputFocused()) return false; - final now = DateTime.now().toUtc(); - if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { - return false; - } - final coord = SessionState.instance.coordinator; - // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). - final digit = _shaderKeys.indexOf(event.logicalKey); - if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { - _lastKeyAt = now; - _pickShader( - digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null); - return true; - } - if (event.logicalKey == LogicalKeyboardKey.keyR && - HardwareKeyboard.instance.isControlPressed) { - _lastKeyAt = now; - _reloadVideo(); - return true; - } - if (event.logicalKey == LogicalKeyboardKey.space) { - _lastKeyAt = now; - coord.toggleReady(); - return true; - } - // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. - final shift = HardwareKeyboard.instance.isShiftPressed; - if (event.character == '>' || (shift && event.logicalKey == LogicalKeyboardKey.keyN)) { - if (coord.nextEpisode.peek() == null) return false; - _lastKeyAt = now; - coord.playNextEpisode(); - return true; - } - if (event.character == '<' || (shift && event.logicalKey == LogicalKeyboardKey.keyP)) { - if (coord.previousEpisode.peek() == null) return false; - _lastKeyAt = now; - coord.playPreviousEpisode(); - return true; - } - // mpv's chapter keys. - if (event.logicalKey == LogicalKeyboardKey.pageDown || - event.logicalKey == LogicalKeyboardKey.pageUp) { - _lastKeyAt = now; - return coord.stepChapter(event.logicalKey == LogicalKeyboardKey.pageDown ? 1 : -1); - } - if (shift && event.logicalKey == LogicalKeyboardKey.arrowRight) { - _lastKeyAt = now; - coord.skipForward(); - return true; - } - if (event.logicalKey == LogicalKeyboardKey.tab) { - _lastKeyAt = now; - _sheetOpen.value = !_sheetOpen.peek(); - coord.markInput(); - return true; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - _lastKeyAt = now; - coord.nudge(10); - return true; - } - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - _lastKeyAt = now; - coord.nudge(-10); - return true; - } - if (event.logicalKey == LogicalKeyboardKey.escape) { - if (_shaderMenuOpen) { - if (mounted) Navigator.of(context, rootNavigator: true).maybePop(); - return true; - } - if (_sheetOpen.peek()) { - _sheetOpen.value = false; - return true; - } - if (_confirmLeave.peek()) { - _confirmLeave.value = false; - return true; - } - if (_isFullscreen.peek()) { - if (mounted) { - Navigator.of(context, rootNavigator: true).maybePop(); - } - return true; - } - _lastKeyAt = now; - _confirmLeave.value = true; - return true; - } - if (event.logicalKey == LogicalKeyboardKey.keyF) { - _lastKeyAt = now; - if (_isFullscreen.peek()) { - if (mounted) { - Navigator.of(context, rootNavigator: true).maybePop(); - } - } else { - _videoKey.currentState?.enterFullscreen(); - } - return true; - } - return false; - } - - @override - Widget build(BuildContext context) { - final coord = SessionState.instance.coordinator; - final controlsTheme = MaterialDesktopVideoControlsThemeData( - playAndPauseOnTap: false, - modifyVolumeOnScroll: false, - keyboardShortcuts: {}, - visibleOnMount: true, - hideMouseOnControlsRemoval: true, - controlsHoverDuration: const Duration(seconds: 3), - // Our seek bar (chapter marks, hover title) sits in the bottom bar, - // which grows by its height; the top bar keeps its row at the top. - displaySeekBar: false, - buttonBarHeight: _barHeight + ChapterSeekBar.height, - topButtonBar: [ - Expanded( - child: Align( - alignment: Alignment.topCenter, - child: SizedBox( - height: _barHeight, - child: Row( - children: [ - const Spacer(), - MaterialDesktopCustomButton( - icon: const Icon(Icons.group), - onPressed: () { - _sheetOpen.value = true; - coord.markInput(); - }, - ), - SignalBuilder( - builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), - ), - ], - ), - ), - ), - ), - ], - bottomButtonBar: [ - Expanded( - child: Column( - children: [ - SignalBuilder( - builder: (context) => PlayerSeekBar( - player: coord.playback.player, - chapters: coord.playback.chapters.value, - ), - ), - Expanded( - child: Row(children: _bottomButtons(coord)), - ), - ], - ), - ), - ], - ); - return FocusTraversalGroup( - descendantsAreTraversable: false, - child: Scaffold( - backgroundColor: Colors.black, - // Player lifetime = session (and each "Reload video"): no live - // player ⇒ no `Video`, so no texture outlives its player. - body: SignalBuilder( - builder: (context) { - final playback = coord.playback; - final gen = playback.generation.value; - if (!playback.alive.value) { - return const Center( - child: Text('player starting…', style: TextStyle(color: Colors.white)), - ); - } - if (gen != _videoGen) { - _videoGen = gen; - _videoKey = GlobalKey(); - } - return MaterialDesktopVideoControlsTheme( - normal: controlsTheme, - fullscreen: controlsTheme, - child: Video( - key: _videoKey, - controller: playback.video, - subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), - controls: _sessionControls, - onEnterFullscreen: () async { - _isFullscreen.value = true; - await defaultEnterNativeFullscreen(); - }, - onExitFullscreen: () async { - _isFullscreen.value = false; - await defaultExitNativeFullscreen(); - }, - ), - ); - }, - ), - ), - ); - } - - static const _barHeight = 56.0; - - List _bottomButtons(SessionCoordinator coord) { - final playback = coord.playback; - void menuOpen(bool open) => _shaderMenuOpen = open; - void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff); - return [ - SignalBuilder( - builder: (context) => EpisodeSkipButton( - focusNode: _noFocus, - episode: coord.previousEpisode.value, - next: false, - onPressed: coord.playPreviousEpisode, - ), - ), - SignalBuilder( - builder: (context) => EpisodeSkipButton( - focusNode: _noFocus, - episode: coord.nextEpisode.value, - next: true, - onPressed: coord.playNextEpisode, - ), - ), - const MaterialDesktopVolumeButton(), - _ReadyControlButton(focusNode: _noFocus), - _MarkAllReadyControlButton(focusNode: _noFocus), - _ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo), - const MaterialDesktopPositionIndicator(), - const Spacer(), - SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward), - SignalBuilder( - builder: (context) => ChaptersControlButton( - focusNode: _noFocus, - chapters: playback.chapters.value, - current: playback.currentChapter.value, - onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), - onMenuOpen: menuOpen, - ), - ), - SignalBuilder( - builder: (context) => TrackMenuButton( - focusNode: _noFocus, - type: JellyfinStreamType.audio, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - SignalBuilder( - builder: (context) => TrackMenuButton( - focusNode: _noFocus, - type: JellyfinStreamType.subtitle, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - ShaderControlButton( - focusNode: _noFocus, - renderMode: playback.renderMode, - onPick: _pickShader, - onMenuOpen: menuOpen, - ), - const MaterialDesktopFullscreenButton(), - ]; - } - - Widget _sessionControls(VideoState state) { - return Stack( - children: [ - // Frozen-picture watchdog samples the route around this anchor. - _frameProbe.anchor(), - // Settings → "Experimental: mpv-style controls" swaps them live. - SessionControlsSwitch( - material: (_) => MaterialDesktopVideoControls(state), - mpv: (_) => _mpvControls(), - ), - _healthOverlay(), - _waitingOverlay(), - _jellyfinOverlay(), - _toastsOverlay(), - _sheetOverlay(), - _swapOverlay(), - _leaveOverlay(), - Positioned.fill( - child: SignalBuilder( - builder: (context) { - final sheetOpen = _sheetOpen.value; - final confirm = _confirmLeave.value; - return WindowDragLayer( - enabled: !confirm && !_swapOpen.value, - exclusions: [ - (s) => Rect.fromLTWH(0, s.height - 120, s.width, 120), - if (sheetOpen) (s) => Rect.fromLTWH(s.width - 380, 0, 380, s.height), - ], - ); - }, - ), - ), - ], - ); - } - - void _openSheet() { - _sheetOpen.value = true; - SessionState.instance.coordinator.markInput(); - } - - /// The experimental uosc-style controls: same actions as the bars above, - /// all through the coordinator (seeks via `player.seek`, the user path). - Widget _mpvControls() { - final coord = SessionState.instance.coordinator; - final playback = coord.playback; - void menuOpen(bool open) => _shaderMenuOpen = open; - void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff); - Widget episode(bool next) => SignalBuilder(builder: (context) { - final e = next ? coord.nextEpisode.value : coord.previousEpisode.value; - if (e == null) return const SizedBox.shrink(); - return UoscButton( - key: ValueKey(next ? 'episode-next' : 'episode-previous'), - icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded, - tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})', - onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode, - ); - }); - return UoscControls( - onDoubleTap: _toggleFullscreen, - top: SignalBuilder( - builder: (context) => - WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), - ), - timeline: (context, expanded) => SignalBuilder( - builder: (context) => UoscTimeline( - position: playback.position.value, - duration: playback.duration.value, - buffered: playback.buffered.value, - chapters: playback.chapters.value, - expanded: expanded, - onSeek: (d) => playback.player.seek(d), - ), - ), - left: [ - UoscMainMenuButton( - onMenuOpen: menuOpen, - actions: [ - (title: 'Change video', hint: null, run: _openSwap), - (title: 'People & chat', hint: 'Tab', run: _openSheet), - (title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo), - (title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll), - (title: 'Fullscreen', hint: 'F', run: _toggleFullscreen), - (title: 'Leave server', hint: 'Esc', run: () => _confirmLeave.value = true), - ], - ), - SignalBuilder( - builder: (context) => UoscTracksButton( - type: JellyfinStreamType.subtitle, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - SignalBuilder( - builder: (context) => UoscTracksButton( - type: JellyfinStreamType.audio, - tracks: playback.tracks.value, - onPick: pickTrack, - onMenuOpen: menuOpen, - ), - ), - UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen), - const SizedBox(width: 12), - SignalBuilder(builder: (context) { - final ready = coord.selfMember.value?.ready ?? false; - return UoscButton( - key: const ValueKey('uosc-ready'), - icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, - label: ready ? 'Ready' : 'Not ready', - color: ready ? Colors.greenAccent : null, - tooltip: 'Ready / not ready (Space)', - onPressed: coord.toggleReady, - ); - }), - UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll), - UoscButton( - icon: Icons.refresh_rounded, - tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture', - onPressed: _reloadVideo, - ), - ], - right: [ - UoscButton( - key: const ValueKey('uosc-skip-forward'), - icon: Icons.fast_forward_rounded, - label: '1:30', - tooltip: 'Skip 1:30 for everyone (Shift+Right)', - onPressed: coord.skipForward, - ), - SignalBuilder( - builder: (context) => UoscChaptersButton( - chapters: playback.chapters.value, - current: playback.currentChapter.value, - duration: playback.duration.value, - onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), - onMenuOpen: menuOpen, - ), - ), - episode(false), - episode(true), - const SizedBox(width: 12), - UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: _openSwap), - UoscButton(icon: Icons.group_outlined, tooltip: 'People & chat (Tab)', onPressed: _openSheet), - StreamBuilder( - stream: playback.player.stream.volume, - initialData: playback.player.state.volume, - builder: (context, v) => UoscVolumeButton( - volume: v.data ?? 100, - onChanged: (x) => playback.player.setVolume(x), - ), - ), - SignalBuilder( - builder: (context) => UoscButton( - icon: _isFullscreen.value ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, - tooltip: 'Fullscreen (F)', - onPressed: _toggleFullscreen, - ), - ), - ], - ); - } - - Widget _healthOverlay() { - final coord = SessionState.instance.coordinator; - return Positioned( - top: 12, - left: 12, - child: SignalBuilder( - builder: (context) { - final h = coord.health.value; - final starve = coord.starving.value; - final retrying = coord.reconnecting.value; - final kicked = coord.kickReason.value; - final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null; - if (!unwell) return const SizedBox.shrink(); - final color = switch (h.severity) { - HealthSeverity.ok => Colors.green, - HealthSeverity.info => Colors.grey, - HealthSeverity.warn => Colors.orange, - HealthSeverity.error => Colors.red, - }; - final IconData icon; - if (retrying) { - icon = Icons.sync; - } else if (h.severity == HealthSeverity.error) { - icon = Icons.error_outline; - } else if (h.severity == HealthSeverity.warn) { - icon = Icons.warning_amber_outlined; - } else { - icon = Icons.hourglass_empty; - } - final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · '); - final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn); - return Container( - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 16), - const SizedBox(width: 8), - Flexible( - child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)), - ), - if (retrying) - TextButton( - style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: () => coord.cancelReconnect(), - child: const Text('Cancel', style: TextStyle(fontSize: 12)), - ), - if (canRetry) - TextButton( - style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: () => coord.retry(), - child: const Text('Retry', style: TextStyle(fontSize: 12)), - ), - ], - ), - ); - }, - ), - ); - } - - Widget _waitingOverlay() { - final coord = SessionState.instance.coordinator; - return SignalBuilder( - builder: (context) { - final r = coord.serverState.value; - final waiting = [ - for (final m in r?.members ?? []) - if (!m.ready) m, - ]; - final show = r != null && r.transport == 'held' && waiting.isNotEmpty; - final selfReady = coord.selfMember.value?.ready ?? false; - return Positioned.fill( - child: Center( - child: Visibility( - visible: show, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - child: Column( - mainAxisSize: .min, - spacing: 12, - children: [ - IgnorePointer( - child: Container( - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadiusGeometry.circular(20)), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: .min, - spacing: 10, - crossAxisAlignment: .center, - children: [ - Text("Waiting for", style: TextTheme.of(context).labelLarge), - Wrap( - alignment: WrapAlignment.center, - spacing: -20, - runSpacing: 8, - children: [ - for (final m in waiting) - Opacity( - opacity: m.stale ? 0.5 : 1.0, - child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96), - ), - ], - ), - ], - ), - ), - ), - ), - if (!selfReady) - FilledButton( - focusNode: _noFocus, - onPressed: () => coord.toggleReady(), - child: const Text("I'm Ready"), - ), - ], - ), - ), - ), - ); - }, - ); - } - - /// Jellyfin: the blocked card, the end-of-episode card (countdown) and - /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / - /// 1:30) above it. Outside the auto-hiding controls, so they also show - /// in fullscreen. - Widget _jellyfinOverlay() { - final coord = SessionState.instance.coordinator; - return Positioned.fill( - child: SignalBuilder( - builder: (context) { - final blocked = coord.jellyfinBlocked.value; - final next = coord.nextEpisode.value; - final countdown = coord.autoAdvance.value; - final finished = coord.serverState.value?.transport == 'finished'; - final pill = coord.nextPromptVisible.value; - final SkipOffer? skip = coord.skipOffer.value; - final showNext = next != null && pill; - return Stack( - children: [ - if (blocked != null) - Center( - child: JellyfinBlockedCard( - blocked: blocked, - onLinkAccount: _openIntegrations, - onRetry: coord.retryJellyfin, - ), - ), - if (next != null && (finished || countdown != null)) - Positioned( - right: 24, - bottom: 138, - child: EndOfEpisodeCard( - next: next, - countdown: countdown, - onPlayNow: coord.playNextEpisode, - onCancel: coord.cancelAutoAdvance, - ), - ) - else if (showNext || skip != null) - Positioned( - right: 24, - bottom: 124, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 10, - children: [ - if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)), - if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), - ], - ), - ), - ], - ); - }, - ), - ); - } - - Widget _swapOverlay() { - final coord = SessionState.instance.coordinator; - return SignalBuilder( - builder: (context) { - final picker = _swapPicker; - if (!_swapOpen.value || picker == null) return const SizedBox.shrink(); - return Positioned.fill( - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: _closeSwap, - child: Container(color: Colors.black54), - ), - ), - Positioned.fill( - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 48, 32, 32), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1400), - child: SwapPanel( - controller: picker, - accounts: coord.jellyfin, - isHost: coord.hostFile != null, - jellyfinStart: _swapStart, - onSwap: coord.swapPicked, - onClose: _closeSwap, - onOpenIntegrations: _openIntegrations, - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _toastsOverlay() { - final coord = SessionState.instance.coordinator; - return Positioned( - right: 12, - top: 56, - child: IgnorePointer( - child: SignalBuilder( - builder: (context) { - final items = coord.toasts.value; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [for (final t in items.reversed.take(5)) _ToastCard(key: ValueKey(t.id), text: t.text)], - ); - }, - ), - ), - ); - } - - Widget _sheetOverlay() { - return SignalBuilder( - builder: (context) { - final open = _sheetOpen.value; - return ExcludeFocus( - excluding: !open, - child: Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: IgnorePointer( - ignoring: !open, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: open ? 1 : 0, - child: GestureDetector( - onTap: () => _sheetOpen.value = false, - child: Container(color: Colors.black54), - ), - ), - ), - ), - Positioned( - top: 20, - bottom: 20, - right: 0, - child: IgnorePointer( - ignoring: !open, - child: AnimatedSlide( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - offset: open ? Offset.zero : const Offset(1, 0), - child: SizedBox( - width: 380, - height: double.infinity, - child: _SheetPanel( - chat: _chat, - chatFocus: _chatFocus, - onChangeVideo: _openSwap, - onClose: () => _sheetOpen.value = false, - onLeave: () { - _sheetOpen.value = false; - _confirmLeave.value = true; - }, - ), - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _leaveOverlay() { - final session = SessionState.instance; - return SignalBuilder( - builder: (context) { - if (!_confirmLeave.value) return const SizedBox.shrink(); - return Positioned.fill( - child: GestureDetector( - onTap: () => _confirmLeave.value = false, - child: Container( - color: Colors.black54, - child: Center( - child: GestureDetector( - onTap: () {}, - child: AlertDialog( - title: const Text('Leave server?'), - content: const Text('You can rejoin right away.'), - actions: [ - TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')), - TextButton( - onPressed: () { - _confirmLeave.value = false; - session.leave(); - }, - child: const Text('Leave'), - ), - ], - ), - ), - ), - ), - ), - ); - }, - ); - } -} - -const _shaderKeys = [ - LogicalKeyboardKey.digit0, - LogicalKeyboardKey.digit1, - LogicalKeyboardKey.digit2, - LogicalKeyboardKey.digit3, - LogicalKeyboardKey.digit4, - LogicalKeyboardKey.digit5, - LogicalKeyboardKey.digit6, -]; - -class _ReadyControlButton extends StatelessWidget { - final FocusNode focusNode; - const _ReadyControlButton({required this.focusNode}); - - @override - Widget build(BuildContext context) { - return SignalBuilder( - builder: (context) { - final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false; - return TextButton( - focusNode: focusNode, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => SessionState.instance.coordinator.toggleReady(), - child: Text(ready ? 'Ready ✓' : 'Not ready', style: const TextStyle(fontSize: 12)), - ); - }, - ); - } -} - -class _MarkAllReadyControlButton extends StatelessWidget { - final FocusNode focusNode; - const _MarkAllReadyControlButton({required this.focusNode}); - - @override - Widget build(BuildContext context) { - return TextButton( - focusNode: focusNode, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => SessionState.instance.coordinator.forcePlayAll(), - child: const Text('Mark everyone ready', style: TextStyle(fontSize: 12)), - ); - } -} - -class _ReloadVideoControlButton extends StatelessWidget { - final FocusNode focusNode; - final VoidCallback onPressed; - const _ReloadVideoControlButton({required this.focusNode, required this.onPressed}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Reload video (Ctrl+R) — fixes a frozen picture', - child: TextButton.icon( - focusNode: focusNode, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: onPressed, - icon: const Icon(Icons.refresh, size: 16), - label: const Text('Reload video', style: TextStyle(fontSize: 12)), - ), - ); - } -} - -class _ToastCard extends StatefulWidget { - final String text; - const _ToastCard({super.key, required this.text}); - - @override - State<_ToastCard> createState() => _ToastCardState(); -} - -class _ToastCardState extends State<_ToastCard> { - bool _shown = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _shown = true); - }); - } - - @override - Widget build(BuildContext context) { - return AnimatedSlide( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - offset: _shown ? Offset.zero : const Offset(1, 0), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: _shown ? 1 : 0, - child: Card( - child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text(widget.text)), - ), - ), - ); - } -} - -class _SheetPanel extends StatelessWidget { - final TextEditingController chat; - final FocusNode chatFocus; - final VoidCallback onChangeVideo; - final VoidCallback onClose; - final VoidCallback onLeave; - const _SheetPanel({required this.chat, required this.chatFocus, required this.onChangeVideo, required this.onClose, required this.onLeave}); - - @override - Widget build(BuildContext context) { - final coord = SessionState.instance.coordinator; - return Material( - borderRadius: BorderRadius.horizontal(left: .circular(20)), - elevation: 8, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: SignalBuilder( - builder: (context) { - final r = coord.serverState.value; - final log = coord.feedLog.value; - final theme = Theme.of(context); - final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold); - final members = r?.members ?? const []; - final readyCount = members.where((m) => m.ready).length; - final playing = r?.transport == 'playing'; - return Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(width: 8), - _StatusChip(playing: playing, hasState: r != null), - ], - ), - const SizedBox(height: 2), - Text( - r == null ? 'connecting…' : '${members.length} member${members.length == 1 ? '' : 's'} · $readyCount ready', - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - IconButton(icon: const Icon(Icons.close), onPressed: onClose), - ], - ), - const Divider(height: 20), - Text('Users (${members.length})', style: sectionStyle), - const SizedBox(height: 2), - for (final m in members) - _MemberRow( - member: m, - isSelf: m.id == coord.sync.memberId, - viaHost: viaHostOf(m, coord.currentRef.value), - ), - const Divider(height: 20), - Text('Activity', style: sectionStyle), - const SizedBox(height: 6), - if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall), - Expanded( - child: ListView.builder( - reverse: true, - itemBuilder: (context, index) => _ActivityRow(item: log.reversed.toList()[index]), - itemCount: log.length, - ), - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: chat, - focusNode: chatFocus, - decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true), - onSubmitted: (v) { - coord.sendChat(v); - chat.clear(); - chatFocus.requestFocus(); - }, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send), - onPressed: () { - coord.sendChat(chat.text); - chat.clear(); - chatFocus.requestFocus(); - }, - ), - ], - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - key: const ValueKey('change-video'), - icon: const Icon(Icons.swap_horiz), - label: const Text('Change video'), - onPressed: onChangeVideo, - ), - ), - const Divider(height: 20), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error), - icon: const Icon(Icons.logout), - label: const Text('Leave server'), - onPressed: onLeave, - ), - ), - const SizedBox(height: 8), - const Center( - child: Text('v$appVersion', style: TextStyle(fontSize: 11, color: Colors.grey)), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _StatusChip extends StatelessWidget { - final bool playing; - final bool hasState; - const _StatusChip({required this.playing, required this.hasState}); - - @override - Widget build(BuildContext context) { - final Color dot; - final String label; - if (!hasState) { - dot = Colors.grey; - label = '…'; - } else if (playing) { - dot = Colors.green; - label = 'Playing'; - } else { - dot = Colors.orange; - label = 'Waiting'; - } - return Container( - decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(999)), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration(shape: BoxShape.circle, color: dot), - ), - const SizedBox(width: 6), - Text(label, style: Theme.of(context).textTheme.labelSmall), - ], - ), - ); - } -} - -/// The member plays the current Jellyfin item through the host's relay. -bool viaHostOf(MemberView m, JellyfinRef? ref) => ref != null && m.jf[ref.serverId] == false; - -class _MemberRow extends StatelessWidget { - final MemberView member; - final bool isSelf; - final bool viaHost; - const _MemberRow({required this.member, required this.isSelf, this.viaHost = false}); - - @override - Widget build(BuildContext context) { - final coord = SessionState.instance.coordinator; - final theme = Theme.of(context); - final status = _memberStatus(member); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - MemberAvatar(avatarBase64: member.avatar, name: member.name, size: 32), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isSelf ? '${member.name} (you)' : member.name, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: status.$2), - ), - const SizedBox(width: 6), - Text( - status.$1, - style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)), - ), - if (viaHost) ...[const SizedBox(width: 6), const ViaHostBadge()], - ], - ), - ], - ), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 18), - padding: EdgeInsets.zero, - onSelected: (v) async { - if (v == 'force') { - coord.forceTarget(member.id, !member.ready); - } else if (v == 'kick') { - final ok = await showKickConfirmDialog(context, member.name); - if (ok == true) coord.kick(member.id); - } - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 'force', child: Text(member.ready ? 'Force unready' : 'Force ready')), - PopupMenuItem( - value: 'kick', - child: Text('Kick', style: TextStyle(color: theme.colorScheme.error)), - ), - ], - ), - ], - ), - ); - } -} - -(String, Color) _memberStatus(MemberView m) { - if (m.stale) return ('Stale', Colors.orange); - if (!m.ready) return ('Not ready', Colors.grey); - if (m.buffered < RelayConstants.minBufferSecs) return ('Buffering ${m.buffered.toStringAsFixed(0)}s', Colors.orange); - return ('Ready', Colors.green); -} - -class _ActivityRow extends StatelessWidget { - final FeedMessage item; - const _ActivityRow({required this.item}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final time = _clock(item.at); - final subtle = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6) ?? Colors.grey; - if (item.action == 'chat') { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.chat_bubble_outline, size: 15, color: Colors.grey), - const SizedBox(width: 8), - Expanded( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: '${item.actor} ', - style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), - ), - TextSpan(text: item.reason, style: theme.textTheme.bodySmall), - ], - ), - ), - ), - const SizedBox(width: 8), - Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)), - ], - ), - ); - } - if (item.actor == 'server') { - final style = _serverFeedStyle(item); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(style.$1, size: 15, color: style.$2), - const SizedBox(width: 8), - Expanded(child: Text(style.$3, style: theme.textTheme.bodySmall)), - const SizedBox(width: 8), - Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)), - ], - ), - ); - } - final style = _feedStyle(item, theme); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(style.$1, size: 15, color: style.$2), - const SizedBox(width: 8), - Expanded( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: item.actor, - style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), - ), - TextSpan(text: ' ${style.$3}', style: theme.textTheme.bodySmall), - ], - ), - ), - ), - const SizedBox(width: 8), - Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)), - ], - ), - ); - } -} - -(IconData, Color, String) _feedStyle(FeedMessage f, ThemeData theme) { - switch (f.action) { - case 'join': - return (Icons.person_add, Colors.green, 'joined'); - case 'leave': - final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}'); - return (Icons.logout, Colors.grey, 'left$reason'); - case 'ready': - return (Icons.check_circle, Colors.green, 'is ready'); - case 'unready': - return (Icons.radio_button_unchecked, Colors.grey, "isn't ready"); - case 'force-ready': - return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready'); - case 'force-unready': - return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready'); - case 'force-play': - return (Icons.play_arrow, Colors.green, 'started playback for everyone'); - case 'force-hold': - return (Icons.pause, Colors.orange, 'held playback for everyone'); - case 'seek': - return (Icons.fast_forward, theme.colorScheme.primary, 'seeked'); - case 'swap': - return (Icons.movie, theme.colorScheme.primary, 'changed the media'); - case 'stale': - return (Icons.cloud_off, Colors.orange, 'lost signal'); - case 'kick': - final reason = f.reason.isEmpty ? '' : ' · ${f.reason}'; - return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason'); - default: - final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; - return (Icons.info_outline, Colors.grey, '${f.action}$target'); - } -} - -(IconData, Color, String) _serverFeedStyle(FeedMessage f) { - switch (f.action) { - case 'resume': - return (Icons.play_arrow, Colors.green, 'Playback resumed'); - default: - return (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'); - } -} - -String _clock(DateTime at) { - final l = at.toLocal(); - String two(int n) => n.toString().padLeft(2, '0'); - return '${two(l.hour)}:${two(l.minute)}'; -} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index d51f9b2..6d40afa 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -14,6 +14,7 @@ import '../playback/playback_controller.dart'; import '../screens/home/picked_media.dart'; import '../sfx/sfx.dart'; import '../state/settings.dart'; +import '../sync/avatar_cache.dart'; import '../sync/state_diff.dart'; import '../sync/sync_client.dart'; import '../sync/user_seek.dart'; @@ -140,6 +141,8 @@ class SessionCoordinator { /// null when this app is not the host. Future<({String name, int size})?> Function(String path)? hostFile; final SyncClient sync = SyncClient(); + /// Member pictures by `avatarHash`, fetched from the server. + late final AvatarCache avatars = AvatarCache(sync); final PlaybackController playback = PlaybackController(); final StateDiff diff = StateDiff(); @@ -953,6 +956,26 @@ class SessionCoordinator { sync.sendChat(text); } + /// Name and/or picture (base64 PNG, empty removes it) change for + /// everyone now; a later fresh join sends them too. + void updateProfile({String? name, String? avatar}) { + final p = _params; + if (p != null) { + _params = ConnectParams( + host: p.host, + port: p.port, + secret: p.secret, + name: name ?? p.name, + avatar: avatar ?? p.avatar, + mediaPath: p.mediaPath, + linkUrl: p.linkUrl, + hostKey: p.hostKey, + media: p.media, + ); + } + sync.sendProfile(name: name, avatar: avatar); + } + void kick(String memberId, {String reason = ''}) { markInput(); sync.sendKick(memberId, reason: reason); @@ -1060,6 +1083,10 @@ class SessionCoordinator { // ------------------------------------------------------------- jellyfin + /// A toast from outside the session (e.g. the Schedule tab's request + /// results), shown like the session's own. + void showToast(String text) => _pushToast(text); + void _pushToast(String text) { _toastId++; toasts.value = [ diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart new file mode 100644 index 0000000..2ab973e --- /dev/null +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -0,0 +1,620 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../jellyfin/accounts.dart'; +import '../jellyfin/browse_scope.dart' show BrowseStart; +import '../jellyfin/session_media.dart'; +import '../screens/home/connect_flow.dart'; +import '../screens/home/health_probe.dart'; +import '../screens/home/media_picker.dart'; +import '../screens/home/profile.dart'; +import '../screens/home/update_indicator.dart'; +import '../state/session_state.dart'; +import '../state/settings.dart'; +import '../update/update_controller.dart'; +import 'chat_panel.dart'; +import 'connect_cards.dart'; +import 'library_tab.dart'; +import 'motion.dart'; +import 'party_strip.dart'; +import 'profile_card.dart'; +import '../schedule/ui/schedule_page.dart'; +import 'session_player.dart'; +import 'settings_tab.dart'; +import 'toasts.dart'; + +/// The one window: the party strip over a body that fades through between +/// the tabs and the full player, the floating mini player, the chat panel +/// and the toasts. Online/offline follows [SessionState.route]. +/// +/// The player ([SessionPlayer], the only `Video`) lives in one layer of +/// the body: full it fills the body (fullscreen: the window, strip +/// hidden), mini it is a 384x216 box snapped to a corner. A mode change +/// fades the layer out, moves it while invisible and fades it back in, so +/// the video is never mounted twice nor remounted. +class AppShell extends StatefulWidget { + final UpdateController? updates; + final bool autoUpdateCheck; + /// Off in tests: the drag area, window buttons and fullscreen need + /// window_manager. + final bool windowControls; + final HealthGetter healthGet; + /// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance]. + final JellyfinAccounts? jellyfin; + /// Stand-in for media_kit's `Video` (tests). + final VideoViewBuilder? videoBuilder; + const AppShell({ + super.key, + this.updates, + this.autoUpdateCheck = true, + this.windowControls = true, + this.healthGet = defaultHealthGet, + this.jellyfin, + this.videoBuilder, + }); + + @override + State createState() => AppShellState(); +} + +class AppShellState extends State with SingleTickerProviderStateMixin { + static const miniSize = Size(384, 216); + static const _miniMargin = 16.0; + + ShellTab _tab = ShellTab.library; + SettingsSection _section = SettingsSection.profile; + + /// Where the player is going ([_mode]) and where it is drawn ([_shown]): + /// they differ for the first part of a change, while it fades out. + PlayerMode _mode = PlayerMode.none; + PlayerMode _shown = PlayerMode.none; + PlayerMode _from = PlayerMode.none; + int _modeSeq = 0; + late final AnimationController _fade = AnimationController(vsync: this, value: 1); + + bool _fullscreen = false; + bool _chatOpen = false; + int _unread = 0; + int _feedSeen = 0; + final _chat = TextEditingController(); + final _chatFocus = FocusNode(); + final _playerKey = GlobalKey(); + + Alignment _corner = Alignment.bottomRight; + Offset? _drag; // mini top-left while dragging + bool _snapping = false; + + final _flow = ConnectFlow(); + late final _join = JoinFields(SessionState.instance.lastForm); + late final _host = HostFields(SessionState.instance.lastForm); + late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance; + late MediaPickerController _picker = _newPicker(); + BrowseStart? _libraryStart; + int _libraryGen = 0; + + /// The next media to come up opens full (just joined, or put up here). + bool _autoFull = false; + int _lastMediaId = -1; + double? _mutedVolume; + bool _dialogOpen = false; + String? _lastKickShown; + final _stops = []; + // Rebuild the shell on these flips, not on every state broadcast. + final _online = computed(() => SessionState.instance.route.value == AppRoute.session); + final _hasMedia = computed(() => + SessionState.instance.route.value == AppRoute.session && + SessionState.instance.coordinator.serverState.value?.media != null); + + PlayerMode get mode => _mode; + PlayerMode get shownMode => _shown; + bool get fullscreen => _fullscreen; + + MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) { + final f = SessionState.instance.lastForm; + final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source); + if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker))); + return c; + } + + @override + void initState() { + super.initState(); + unawaited(Profile.instance.ensureLoaded()); + // Reads linked servers and checks their tokens in the background. + _jellyfin.ensureLoaded(); + if (widget.autoUpdateCheck) _autoCheck(); + _flow.onKicked = _showKicked; + final session = SessionState.instance; + final coord = session.coordinator; + _feedSeen = coord.feedLog.peek().length; + _stops.add(effect(() { + final online = session.route.value == AppRoute.session; + // Set now, not later: the media effect below may run first in this + // same flush, and its scheduled show reads these. + _autoFull = online; + if (!online) _lastMediaId = -1; + untracked(() => _later(() => online ? _wentOnline() : _wentOffline())); + })); + // Something comes up while the player is closed: show it (full right + // after joining or a put-up of ours, else as the mini player). + _stops.add(effect(() { + final r = coord.serverState.value; + final online = session.route.value == AppRoute.session; + if (!online || r == null || r.media == null) return; + if (r.mediaId == _lastMediaId) return; + _lastMediaId = r.mediaId; + _later(() { + if (_mode == PlayerMode.none) { + _show(_autoFull ? PlayerMode.full : PlayerMode.mini); + } else if (_autoFull) { + _show(PlayerMode.full); + } + _autoFull = false; + }); + })); + // Chat that arrives while the panel is closed. + _stops.add(effect(() { + final log = coord.feedLog.value; + final fresh = log.length < _feedSeen ? log : log.sublist(_feedSeen); + _feedSeen = log.length; + final chats = fresh.where((f) => f.action == 'chat').length; + if (chats == 0) return; + untracked(() => _later(() { + if (!_chatOpen) setState(() => _unread += chats); + })); + })); + // Kicked: back offline, and say so. + _stops.add(effect(() { + final kick = coord.kickReason.value; + if (kick == null) { + _lastKickShown = null; + return; + } + untracked(() { + if (session.route.value == AppRoute.session) session.route.value = AppRoute.home; + }); + if (kick.isNotEmpty) _later(() => _showKicked(kick)); + })); + // The hosted server died mid-session (SessionState leaves): say so. + _stops.add(effect(() { + final crash = session.serverCrash.value; + if (crash == null) return; + _later(() { + session.serverCrash.value = null; + _alert('Server crashed', crash); + }); + })); + } + + @override + void dispose() { + for (final stop in _stops) { + stop(); + } + _fade.dispose(); + _online.dispose(); + _hasMedia.dispose(); + _flow.dispose(); + _join.dispose(); + _host.dispose(); + _picker.dispose(); + _chat.dispose(); + _chatFocus.dispose(); + if (_fullscreen) _setWindowFullscreen(false); + super.dispose(); + } + + /// Runs [f] after the current signal notification / build. + void _later(VoidCallback f) => scheduleMicrotask(() { + if (mounted) f(); + }); + + Future _autoCheck() async { + final s = AppSettings.instance; + await s.ensureLoaded(); + if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates); + } + + void _wentOnline() { + _unread = 0; + _feedSeen = SessionState.instance.coordinator.feedLog.peek().length; + // Hosting starts with nothing up: the library is where to pick it. + setState(() => _tab = ShellTab.library); + } + + void _wentOffline() { + _mutedVolume = null; + if (_fullscreen) _toggleFullscreen(); + setState(() { + _chatOpen = false; + _unread = 0; + }); + _show(PlayerMode.none); + } + + // ----------------------------------------------------------- player mode + + /// Fade through to [m]: the player layer (and, when full is involved, + /// the tabs) fades out, moves while invisible, then fades in. + Future _show(PlayerMode m) async { + if (m == _mode) return; + final seq = ++_modeSeq; + if (m != PlayerMode.none) _unmute(); + setState(() { + _from = _shown; + _mode = m; + }); + if (m == PlayerMode.full) FocusManager.instance.primaryFocus?.unfocus(); + if (_shown != PlayerMode.none || m == PlayerMode.full) { + await _fade.animateTo(0, + duration: fadeThroughTime * fadeOutPart, curve: Curves.easeIn); + } else { + _fade.value = 0; + } + if (!mounted || seq != _modeSeq) return; + setState(() => _shown = m); + await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease); + } + + void _expand() => _show(PlayerMode.full); + + void _collapse() { + if (_fullscreen) _toggleFullscreen(); + _show(PlayerMode.mini); + } + + /// Mini's ×: stop watching but stay in the server (muted until shown). + void _closePlayer() { + final playback = SessionState.instance.coordinator.playback; + if (playback.alive.peek()) { + try { + _mutedVolume = playback.player.state.volume; + playback.player.setVolume(0); + } catch (_) {} + } + _show(PlayerMode.none); + } + + void _unmute() { + final v = _mutedVolume; + if (v == null) return; + _mutedVolume = null; + final playback = SessionState.instance.coordinator.playback; + if (!playback.alive.peek()) return; + try { + playback.player.setVolume(v); + } catch (_) {} + } + + Future _setWindowFullscreen(bool on) async { + if (!widget.windowControls) return; + try { + await windowManager.setFullScreen(on); + } catch (_) { + // No window manager. + } + } + + void _toggleFullscreen() { + final on = !_fullscreen; + setState(() => _fullscreen = on); + if (on && _mode != PlayerMode.full) _show(PlayerMode.full); + _setWindowFullscreen(on); + } + + void _selectTab(ShellTab t) { + if (_fullscreen) _toggleFullscreen(); + setState(() => _tab = t); + if (_mode == PlayerMode.full) _show(PlayerMode.mini); + } + + void _openSettings(SettingsSection s) { + setState(() => _section = s); + _selectTab(ShellTab.settings); + } + + void _toggleChat() => setState(() { + _chatOpen = !_chatOpen; + _unread = 0; + if (!_chatOpen) _chatFocus.unfocus(); + }); + + // --------------------------------------------------------------- library + + /// "Change video": the library, opened on the series of the episode + /// playing (season selected, episode highlighted), else where it was. + void _changeVideo() { + final coord = SessionState.instance.coordinator; + coord.markInput(); + final ref = coord.currentRef.peek(); + final pick = ref == null ? null : pickForRef(_jellyfin, ref); + final plan = coord.jellyfinPlan.peek(); + final old = _picker; + _picker = pick == null + ? _newPicker() + : _newPicker(jellyfin: pick, source: MediaKind.jellyfin); + old.dispose(); + _libraryStart = ref != null && plan is OwnPlay && plan.item != null + ? BrowseStart.seriesOf(ref.serverId, plan.item!) + : null; + _libraryGen++; + _selectTab(ShellTab.library); + } + + /// Library → "Put it up for everyone"; the player opens full. + Future _putUp(PickedMedia media) async { + final session = SessionState.instance; + if (media is JellyfinMedia) { + try { + putUpJellyfinMedia(_jellyfin, media); + } on FormatException catch (e) { + return e.message; + } + unawaited(_jellyfin.rememberPick(media)); + } + final error = await session.coordinator.swapPicked(media); + if (error != null || !mounted) return error; + final f = session.lastForm; + f + ..linkUrl = media is LinkMedia ? media.url : f.linkUrl + ..mediaPath = media is FileMedia ? media.path : f.mediaPath; + unawaited(session.saveForm().catchError((Object _) {})); + _autoFull = true; + _show(PlayerMode.full); + return null; + } + + // --------------------------------------------------------------- dialogs + + Future _confirmLeave() async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Leave server?'), + content: const Text('You can rejoin right away.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton( + key: const ValueKey('confirm-leave'), + onPressed: () => Navigator.pop(context, true), + child: const Text('Leave'), + ), + ], + ), + ); + if (ok == true) await SessionState.instance.leave(); + } + + Future _showKicked(String reason) async { + if (reason == _lastKickShown) return; + _lastKickShown = reason; + await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason); + } + + Future _alert(String title, String text) async { + if (!mounted || _dialogOpen) return; + _dialogOpen = true; + try { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(text), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ), + ); + } finally { + _dialogOpen = false; + } + } + + void _connect(ConnectMode mode) => showConnectCard( + context, + mode: mode, + flow: _flow, + join: _join, + host: _host, + healthGet: widget.healthGet, + ); + + // ----------------------------------------------------------------- build + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings), + }, + child: Scaffold( + backgroundColor: _fullscreen ? Colors.black : scheme.surface, + body: SignalBuilder(builder: (context) { + final online = _online.value; + final hasMedia = _hasMedia.value; + return Column( + children: [ + if (!_fullscreen) + PartyStrip( + key: const ValueKey('strip'), + tab: _tab, + onTab: _selectTab, + chatOpen: _chatOpen, + unread: _unread, + onChat: _toggleChat, + onJoin: () => _connect(ConnectMode.join), + onHost: () => _connect(ConnectMode.host), + onLeave: _confirmLeave, + onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), + onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, + updates: widget.updates, + windowControls: widget.windowControls, + ), + Expanded( + key: const ValueKey('body'), + child: Row( + children: [ + Expanded(child: _body(online)), + AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.centerLeft, + child: _chatOpen && online + ? ChatPanel(chat: _chat, chatFocus: _chatFocus, onClose: _toggleChat) + : const SizedBox(height: double.infinity), + ), + ], + ), + ), + ], + ); + }), + ), + ); + } + + Widget _body(bool online) { + final coord = SessionState.instance.coordinator; + final tabBody = switch (_tab) { + ShellTab.library => LibraryTab( + key: ValueKey('library-$_libraryGen'), + controller: _picker, + accounts: _jellyfin, + online: online, + isHost: coord.hostFile != null, + jellyfinStart: _libraryStart, + onPutUp: _putUp, + onOpenIntegrations: () => _openSettings(SettingsSection.integrations), + ), + ShellTab.schedule => SchedulePage( + online: online, + onPutUp: _putUp, + onToast: coord.showToast, + ), + ShellTab.settings => SettingsTab( + section: _section, + onSection: (s) => setState(() => _section = s), + onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), + updates: widget.updates, + jellyfin: _jellyfin, + ), + }; + final full = _shown == PlayerMode.full; + final fullInvolved = _mode == PlayerMode.full || _from == PlayerMode.full; + return LayoutBuilder(builder: (context, box) { + final area = box.biggest; + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: _fade, + builder: (context, child) { + // The tabs take part in the fade only when the full player + // comes or goes; a mini player appearing leaves them be. + final v = full ? 0.0 : (fullInvolved ? _fade.value : 1.0); + return Opacity( + opacity: v, + child: Transform.scale(scale: 0.985 + 0.015 * v, child: child), + ); + }, + child: IgnorePointer( + ignoring: full, + child: ExcludeFocus( + excluding: full, + child: TickerMode( + enabled: !full, + child: fadeThrough(KeyedSubtree(key: ValueKey(_tab), child: tabBody)), + ), + ), + ), + ), + ), + if (online && _shown != PlayerMode.none) _playerLayer(area), + Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()), + ], + ); + }); + } + + Offset _cornerPos(Size area, Alignment a, Size size) => Offset( + a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity), + a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity), + ); + + Widget _playerLayer(Size area) { + final mini = _shown == PlayerMode.mini; + final size = mini + ? Size(miniSize.width.clamp(0, area.width), miniSize.height.clamp(0, area.height)) + : area; + final pos = mini ? (_drag ?? _cornerPos(area, _corner, size)) : Offset.zero; + return AnimatedPositioned( + key: const ValueKey('player-layer'), + duration: _snapping ? easeTime : Duration.zero, + curve: ease, + left: pos.dx, + top: pos.dy, + width: size.width, + height: size.height, + onEnd: () => _snapping = false, + child: AnimatedBuilder( + animation: _fade, + builder: (context, child) { + final v = _fade.value; + return IgnorePointer( + ignoring: v < 0.5 || _shown != _mode, + child: Opacity( + opacity: v, + child: Transform.scale(scale: mini ? 0.97 + 0.03 * v : 0.985 + 0.015 * v, child: child), + ), + ); + }, + // Same widgets in both modes (only their settings change), so the + // player below never moves in the element tree. + child: GestureDetector( + onPanStart: mini ? (_) => setState(() => _drag = pos) : null, + onPanUpdate: mini ? (d) => setState(() => _drag = _drag! + d.delta) : null, + onPanEnd: mini + ? (_) => setState(() { + final c = _drag! + Offset(size.width / 2, size.height / 2); + _corner = Alignment(c.dx < area.width / 2 ? -1 : 1, c.dy < area.height / 2 ? -1 : 1); + _drag = null; + _snapping = true; + }) + : null, + child: DoubleClickListener( + onDoubleClick: mini ? _expand : () {}, + skipTop: 40, + skipBottom: 60, + child: Material( + elevation: mini ? 16 : 0, + color: Colors.black, + borderRadius: BorderRadius.circular(mini ? 14 : 0), + clipBehavior: Clip.antiAlias, + child: KeyedSubtree( + key: _playerKey, + child: SessionPlayer( + mode: _shown, + fullscreen: _fullscreen, + chatOpen: _chatOpen, + unread: _unread, + onExpand: _expand, + onCollapse: _collapse, + onClose: _closePlayer, + onToggleFullscreen: _toggleFullscreen, + onToggleChat: _toggleChat, + onChangeVideo: _changeVideo, + onOpenIntegrations: () => _openSettings(SettingsSection.integrations), + onLeave: _confirmLeave, + videoBuilder: widget.videoBuilder, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart new file mode 100644 index 0000000..de3e247 --- /dev/null +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -0,0 +1,321 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../screens/home/profile.dart'; +import '../session/session_coordinator.dart'; +import '../state/session_state.dart'; +import 'motion.dart'; + +/// What an avatar shows: who, their picture or initial on [color], and +/// the status the ring and badges carry. +class AvatarInfo { + final String id; + final String name; + final Color color; + final ImageProvider? image; + final bool ready, buffering, relaying, stale, host, self; + const AvatarInfo({ + this.id = '', + required this.name, + required this.color, + this.image, + this.ready = false, + this.buffering = false, + this.relaying = false, + this.stale = false, + this.host = false, + this.self = false, + }); +} + +/// Stable colour for someone else (colours are not on the wire). +Color colorForName(String name) { + const palette = [ + Colors.teal, + Colors.orange, + Colors.pink, + Colors.blue, + Colors.green, + Colors.indigo, + Colors.amber, + Colors.cyan, + Colors.red, + Colors.deepPurple, + ]; + var h = 0; + for (final c in name.codeUnits) { + h = (h * 31 + c) & 0x7fffffff; + } + return palette[h % palette.length]; +} + +final _images = {}; + +MemoryImage _memo(Object key, Uint8List bytes) { + final hit = _images[key]; + if (hit != null && identical(hit.bytes, bytes)) return hit; + if (_images.length > 64) _images.clear(); + return _images[key] = MemoryImage(bytes); +} + +/// The one lookup for a member's picture: your own from the profile (no +/// round trip), anyone else's from the session's avatar cache by hash. +/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture] +/// (see [PartyWatch]). +ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) { + if (m.id.isNotEmpty && m.id == coord.sync.memberId) { + final own = Profile.instance.picture.value; + if (own != null) return _memo('self', own); + } + final bytes = coord.avatars.bytesFor(m.avatarHash); + return bytes == null ? null : _memo(m.avatarHash, bytes); +} + +/// Your own avatar from the profile (also offline). +AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) { + final p = Profile.instance; + final png = p.picture.value; + final name = p.name.value; + return AvatarInfo( + name: name.isEmpty ? '?' : name, + color: p.color.value, + image: png == null ? null : _memo('self', png), + ready: ready, + host: host, + self: true, + ); +} + +/// [m] as the party strip, toasts and the chat show it. +AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) { + final self = m.id.isNotEmpty && m.id == coord.sync.memberId; + final relay = r == null ? null : stateMediaRelay(r); + final playing = r?.media != null; + return AvatarInfo( + id: m.id, + name: m.name, + color: self ? Profile.instance.color.value : colorForName(m.name), + image: memberAvatarImage(m, coord), + ready: m.ready, + buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs, + relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, + stale: m.stale, + // Only your own app knows it hosts: the state carries no host flag. + host: self && coord.hostFile != null, + self: self, + ); +} + +/// Everyone in the server, from the latest state. +List partyAvatars([SessionCoordinator? coord]) { + final c = coord ?? SessionState.instance.coordinator; + final r = c.serverState.value; + return [for (final m in r?.members ?? const []) memberAvatarInfo(m, r, c)]; +} + +/// Rebuilds [builder] when the party state, your profile or a member's +/// picture changes. +class PartyWatch extends StatelessWidget { + final WidgetBuilder builder; + const PartyWatch({super.key, required this.builder}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: SessionState.instance.coordinator.avatars, + builder: (context, _) => SignalBuilder(builder: (context) => builder(context)), + ); +} + +/// Circle avatar with a status ring outside it and a gap in the background +/// colour, so overlapping neighbours never hide the status. Crown = host, +/// tower = passing the video on, spinner = buffering, dimmed = stale. +class StatusAvatar extends StatelessWidget { + final AvatarInfo info; + final double size; + + /// Host crown and relay tower; off where the context already says it. + final bool badges; + + /// Ring colour when not ready (the profile shows none at all). + final bool ring; + const StatusAvatar({super.key, required this.info, required this.size, this.badges = true, this.ring = true}); + + @override + Widget build(BuildContext context) { + final bg = Theme.of(context).colorScheme.surfaceContainerLowest; + final ringColor = !ring + ? bg + : info.stale + ? Colors.grey.shade700 + : info.buffering + ? Colors.amber + : info.ready + ? Colors.greenAccent.shade400 + : Colors.grey; + final badge = size * 0.42; + // Ring and gap scale with the avatar (2 px at strip size). + final stroke = (size * 0.055).clamp(2.0, 4.0); + final initial = info.name.isEmpty ? '?' : info.name.characters.first.toUpperCase(); + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Fills the box: a non-positioned Stack child only gets loose + // constraints, and CircleAvatar then falls back to 40 px. + Positioned.fill( + child: Container( + decoration: BoxDecoration(shape: BoxShape.circle, color: ringColor), + padding: EdgeInsets.all(stroke), + child: Container( + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + padding: EdgeInsets.all(stroke), + child: Opacity( + opacity: info.stale ? 0.4 : 1, + child: CircleAvatar( + radius: size / 2, + backgroundColor: info.color, + backgroundImage: info.image, + child: info.image != null + ? null + : Text(initial, style: TextStyle(fontSize: size * 0.38, color: Colors.white)), + ), + ), + ), + ), + ), + if (info.buffering) + Positioned.fill(child: CircularProgressIndicator(strokeWidth: stroke, color: ringColor)), + if (badges && info.host) + Positioned( + left: -4, + top: -6, + child: Transform.rotate( + angle: -0.4, + child: Icon( + Icons.workspace_premium, + size: badge, + color: Colors.amber, + shadows: const [Shadow(blurRadius: 3, color: Colors.black)], + ), + ), + ), + if (badges && info.relaying) + Positioned( + right: -3, + bottom: -3, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + child: Icon(Icons.cell_tower, size: badge * 0.8, color: Colors.lightBlueAccent), + ), + ), + ], + ), + ); + } +} + +/// Overlapping avatars, host first then ready (the stack itself reads as +/// progress); they spread apart on hover. "+N" past [shown]. +class AvatarStack extends StatefulWidget { + final List members; + final VoidCallback? onTap; + final int shown; + final double size; + const AvatarStack({super.key, required this.members, this.onTap, this.shown = 5, this.size = 42}); + + @override + State createState() => _AvatarStackState(); +} + +class _AvatarStackState extends State { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final size = widget.size; + int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2); + final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b)); + final visible = sorted.take(widget.shown).toList(); + final extra = sorted.length - visible.length; + final step = _hover ? size + 6 : size * 0.62; + final count = visible.length + (extra > 0 ? 1 : 0); + final width = count == 0 ? 0.0 : step * (count - 1) + size; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + cursor: widget.onTap == null ? MouseCursor.defer : SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + key: const ValueKey('avatar-stack'), + duration: easeTime, + curve: ease, + width: width, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = visible.length - 1; i >= 0; i--) + AnimatedPositioned( + key: ValueKey('stack-${visible[i].id}-${visible[i].name}'), + duration: easeTime, + curve: ease, + left: i * step, + top: 0, + child: Tooltip( + message: visible[i].name, + child: StatusAvatar(info: visible[i], size: size), + ), + ), + if (extra > 0) + AnimatedPositioned( + duration: easeTime, + curve: ease, + left: visible.length * step, + top: 0, + child: CircleAvatar( + radius: size / 2, + child: Text('+$extra', style: const TextStyle(fontSize: 12)), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Small coloured label ("host", "passing the video on"). +class Pill extends StatelessWidget { + final String text; + final Color color; + const Pill(this.text, this.color, {super.key}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)), + ); +} + +/// Someone by display name (feed lines carry names, not ids): from the +/// latest state when still there, else their initial on a stable colour. +AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) { + final c = coord ?? SessionState.instance.coordinator; + final r = c.serverState.value; + for (final m in r?.members ?? const []) { + if (m.name == name) return memberAvatarInfo(m, r, c); + } + return AvatarInfo(name: name, color: colorForName(name)); +} diff --git a/apps/desktop/lib/src/shell/chat_panel.dart b/apps/desktop/lib/src/shell/chat_panel.dart new file mode 100644 index 0000000..3a25984 --- /dev/null +++ b/apps/desktop/lib/src/shell/chat_panel.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../state/session_state.dart'; +import 'avatar.dart'; + +/// Side panel: chat with avatars, other activity as quiet lines, and the +/// message field. Width is fixed; the shell slides it in and out. +class ChatPanel extends StatelessWidget { + static const width = 340.0; + final TextEditingController chat; + final FocusNode chatFocus; + final VoidCallback onClose; + const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose}); + + void _send() { + SessionState.instance.coordinator.sendChat(chat.text); + chat.clear(); + chatFocus.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + key: const ValueKey('chat-panel'), + width: width, + color: scheme.surfaceContainerLow, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), + child: Row( + children: [ + const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)), + const Spacer(), + IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)), + ], + ), + ), + Expanded( + child: PartyWatch(builder: (context) { + final log = SessionState.instance.coordinator.feedLog.value; + if (log.isEmpty) { + return Center( + child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ); + } + return ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: log.length, + itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]), + ); + }), + ), + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + key: const ValueKey('chat-field'), + controller: chat, + focusNode: chatFocus, + onSubmitted: (_) => _send(), + decoration: InputDecoration( + hintText: 'Message (Enter)', + isDense: true, + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)), + ), + ), + ), + ], + ), + ); + } +} + +/// One feed line: a chat message with the sender's avatar, anything else +/// as an icon and "who did what". +class ActivityRow extends StatelessWidget { + final FeedMessage item; + const ActivityRow({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final time = _clock(item.at); + if (item.action == 'chat') { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text(item.actor, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700)), + ), + const SizedBox(width: 6), + Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)), + ], + ), + SelectableText(item.reason), + ], + ), + ), + ], + ), + ); + } + final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme); + final line = item.actor == 'server' ? text : '${item.actor} $text'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const SizedBox(width: 8), + Icon(icon, size: 14, color: color), + const SizedBox(width: 8), + Expanded(child: Text(line, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12))), + Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)), + ], + ), + ); + } +} + +/// Icon, colour and wording of a member's feed action. +(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) { + switch (f.action) { + case 'join': + return (Icons.login, Colors.green, 'joined'); + case 'leave': + final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}'); + return (Icons.logout, Colors.grey, 'left$reason'); + case 'ready': + return (Icons.check_circle, Colors.green, 'is ready'); + case 'unready': + return (Icons.radio_button_unchecked, Colors.grey, "isn't ready"); + case 'force-ready': + return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready'); + case 'force-unready': + return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready'); + case 'force-play': + return (Icons.play_arrow, Colors.green, 'started playback for everyone'); + case 'force-hold': + return (Icons.pause, Colors.orange, 'held playback for everyone'); + case 'seek': + return (Icons.fast_forward, scheme.primary, 'seeked'); + case 'swap': + return (Icons.swap_horiz, scheme.primary, 'changed the media'); + case 'stale': + return (Icons.cloud_off, Colors.orange, 'lost signal'); + case 'kick': + final reason = f.reason.isEmpty ? '' : ' · ${f.reason}'; + return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason'); + default: + final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; + return (Icons.info_outline, Colors.grey, '${f.action}$target'); + } +} + +/// The server's own lines (pause / resume). +(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) { + 'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'), + _ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'), + }; + +String _clock(DateTime at) { + final l = at.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(l.hour)}:${two(l.minute)}'; +} diff --git a/apps/desktop/lib/src/shell/connect_cards.dart b/apps/desktop/lib/src/shell/connect_cards.dart new file mode 100644 index 0000000..5188843 --- /dev/null +++ b/apps/desktop/lib/src/shell/connect_cards.dart @@ -0,0 +1,489 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show RelayConstants; + +import '../screens/home/connect_flow.dart'; +import '../screens/home/connect_status.dart'; +import '../screens/home/health_probe.dart'; +import '../state/session_state.dart'; +import 'motion.dart'; + +/// Join card text, owned by the shell so it survives the card closing. +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(); + } +} + +/// Host card text, owned by the shell so it survives the card closing. +class HostFields { + HostFields(ConnectForm f) + : port = TextEditingController(text: f.hostPort), + secret = TextEditingController(text: f.hostSecret); + final TextEditingController port; + final TextEditingController secret; + + void dispose() { + port.dispose(); + secret.dispose(); + } +} + +/// The strip's Join / Host buttons: a card over the window. It closes +/// itself once the attempt got in; a failed one shows its error inside. +Future showConnectCard( + BuildContext context, { + required ConnectMode mode, + required ConnectFlow flow, + required JoinFields join, + required HostFields host, + HealthGetter healthGet = defaultHealthGet, +}) => + showPopover( + context, + dim: true, + at: const Alignment(0.8, -0.75), + child: _CloseWhenOnline( + child: mode == ConnectMode.host + ? HostCard(flow: flow, fields: host) + : JoinCard(flow: flow, fields: join, healthGet: healthGet), + ), + ); + +class _CloseWhenOnline extends StatefulWidget { + final Widget child; + const _CloseWhenOnline({required this.child}); + + @override + State<_CloseWhenOnline> createState() => _CloseWhenOnlineState(); +} + +class _CloseWhenOnlineState extends State<_CloseWhenOnline> { + late final void Function() _stop; + + @override + void initState() { + super.initState(); + _stop = effect(() { + if (SessionState.instance.route.value != AppRoute.session) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).maybePop(); + }); + }); + } + + @override + void dispose() { + _stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// Card frame shared by Join and Host. +class _Card extends StatelessWidget { + final String title; + final List children; + const _Card({required this.title, required this.children}); + + @override + Widget build(BuildContext context) => Material( + elevation: 12, + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: 360, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ...children, + ], + ), + ), + ), + ); +} + +const _dense = InputDecoration(isDense: true); + +/// Recent servers as one-click rows (with live status), then address and +/// password. +class JoinCard extends StatelessWidget { + final ConnectFlow flow; + final JoinFields fields; + final HealthGetter healthGet; + const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet}); + + 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) { + return ListenableBuilder( + listenable: flow, + builder: (context, _) { + final busy = flow.busy == ConnectMode.join; + return _Card( + title: 'Join a server', + children: [ + 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 Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('Recent', style: TextStyle(color: Colors.grey)), + RecentServerRows( + servers: list, + enabled: flow.busy == null, + healthGet: healthGet, + onJoin: _joinRecent, + onForget: SessionState.instance.forgetServer, + ), + const Divider(), + ], + ); + }), + AbsorbPointer( + absorbing: busy, + child: Column( + key: const ValueKey('join-row'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('join-address'), + controller: fields.host, + autofocus: true, + decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'), + onChanged: (_) => flow.clearError(ConnectMode.join), + onSubmitted: (_) => _join(), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 72, + child: TextField( + key: const ValueKey('join-port'), + controller: fields.port, + decoration: _dense.copyWith(labelText: 'Port'), + keyboardType: TextInputType.number, + onSubmitted: (_) => _join(), + ), + ), + ], + ), + const SizedBox(height: 8), + TextField( + key: const ValueKey('join-password'), + controller: fields.secret, + obscureText: true, + decoration: _dense.copyWith(labelText: 'Password (optional)'), + onSubmitted: (_) => _join(), + ), + ], + ), + ), + const SizedBox(height: 16), + ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true), + ConnectFeedback(flow: flow, mode: ConnectMode.join), + ], + ); + }, + ); + } +} + +/// Port and password; what to watch is picked afterwards, in the library. +class HostCard extends StatelessWidget { + final ConnectFlow flow; + final HostFields fields; + const HostCard({super.key, required this.flow, required this.fields}); + + void _host() => flow.submit( + ConnectMode.host, + (f) => f + ..hostPort = fields.port.text.trim() + ..hostSecret = fields.secret.text, + ); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: flow, + builder: (context, _) => _Card( + title: 'Host a server', + children: [ + AbsorbPointer( + absorbing: flow.busy == ConnectMode.host, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('host-port'), + controller: fields.port, + autofocus: true, + decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'), + keyboardType: TextInputType.number, + onChanged: (_) => flow.clearError(ConnectMode.host), + onSubmitted: (_) => _host(), + ), + const SizedBox(height: 8), + ListenableBuilder( + listenable: fields.secret, + builder: (context, _) => TextField( + key: const ValueKey('host-password'), + controller: fields.secret, + obscureText: true, + decoration: _dense.copyWith( + labelText: 'Password (optional)', + helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null, + ), + onSubmitted: (_) => _host(), + ), + ), + _HostedRecents(fields: fields, enabled: flow.busy == null), + ], + ), + ), + const SizedBox(height: 16), + ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true), + ConnectFeedback(flow: flow, mode: ConnectMode.host), + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'You pick what to watch afterwards, in the library.', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + ], + ), + ); + } +} + +/// 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: 12), + 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), + ), + ], + ), + ); + }); + } +} + +/// Recent servers as rows; polls each one's `/health` every +/// [healthPollEvery] while mounted (i.e. while the Join card is open). +class RecentServerRows extends StatefulWidget { + final List servers; + final bool enabled; + final HealthGetter healthGet; + final ValueChanged onJoin; + final ValueChanged onForget; + const RecentServerRows({ + super.key, + required this.servers, + required this.enabled, + required this.healthGet, + required this.onJoin, + required this.onForget, + }); + + @override + State createState() => _RecentServerRowsState(); +} + +class _RecentServerRowsState extends State { + final _health = {}; + final _inFlight = {}; + Timer? _timer; + + @override + void initState() { + super.initState(); + _pollAll(); + _timer = Timer.periodic(healthPollEvery, (_) => _pollAll()); + } + + @override + void didUpdateWidget(RecentServerRows 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) { + return Column( + children: [ + for (final r in widget.servers) + _RecentRow( + key: ValueKey('recent-${r.key}'), + server: r, + health: _health[r.key], + enabled: widget.enabled, + onJoin: () => widget.onJoin(r), + onForget: () => widget.onForget(r), + ), + ], + ); + } +} + +class _RecentRow extends StatelessWidget { + final RecentServer server; + /// Null until the first probe answers. + final ServerHealth? health; + final bool enabled; + final VoidCallback onJoin; + final VoidCallback onForget; + const _RecentRow({ + super.key, + required this.server, + required this.health, + required this.enabled, + required this.onJoin, + required this.onForget, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + 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 ListTile( + dense: true, + contentPadding: const EdgeInsets.only(left: 4), + enabled: enabled, + leading: Icon(server.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 18), + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Wrap( + spacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(address, style: const TextStyle(color: Colors.grey)), + Text(status, key: const ValueKey('recent-status'), style: TextStyle(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: 6, vertical: 1), + decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)), + child: Text('needs update', style: TextStyle(fontSize: 11, color: scheme.onErrorContainer)), + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Forget', + visualDensity: VisualDensity.compact, + iconSize: 16, + onPressed: onForget, + icon: const Icon(Icons.close), + ), + const Icon(Icons.login, size: 18), + ], + ), + onTap: onJoin, + ); + } +} diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart new file mode 100644 index 0000000..6888e34 --- /dev/null +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart' show HelloMedia; + +import '../jellyfin/accounts.dart'; +import '../jellyfin/browse_scope.dart' show BrowseStart; +import '../jellyfin/session_media.dart'; +import '../screens/home/media_picker.dart'; +import '../screens/session/swap_panel.dart'; + +/// What putting up a Jellyfin pick sends: its ref (no URL, no token). +/// Throws [FormatException] with a user-facing message when the server is +/// not linked any more or its sign-in expired. +HelloMedia putUpJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) { + if (accounts.account(media.serverId) == null) { + throw const FormatException('that Jellyfin server is not linked any more — pick again'); + } + if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) { + throw const FormatException('Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations'); + } + return mediaForPick(media); +} + +/// Brings back the last Jellyfin pick (kept in the Jellyfin box) once the +/// accounts are read, unless something else was picked meanwhile or +/// [alive] says the picker is gone. +Future restoreLastPick( + MediaPickerController c, JellyfinAccounts accounts, bool Function() alive) async { + final hadMedia = c.selection != null; + try { + await accounts.ensureLoaded(); + } catch (_) { + return; + } + final pick = accounts.lastPick; + if (!alive() || pick == null || c.jellyfin != null) return; + if (accounts.account(pick.serverId) == null) return; + final source = c.source; + c.pickJellyfin(pick); + if (hadMedia) c.source = source; +} + +/// Library (round 1): the media picker as the "put it up" view. Offline +/// it browses, with a hint that watching needs a server. +class LibraryTab extends StatelessWidget { + final MediaPickerController controller; + final JellyfinAccounts accounts; + final bool online; + final bool isHost; + final BrowseStart? jellyfinStart; + /// Puts the pick up; returns an error text, or null when sent. + final Future Function(PickedMedia media) onPutUp; + final VoidCallback? onPutUpDone; + final VoidCallback? onOpenIntegrations; + const LibraryTab({ + super.key, + required this.controller, + required this.accounts, + required this.online, + required this.isHost, + required this.onPutUp, + this.onPutUpDone, + this.jellyfinStart, + this.onOpenIntegrations, + }); + + @override + Widget build(BuildContext context) { + return SwapPanel( + controller: controller, + accounts: accounts, + isHost: isHost, + jellyfinStart: jellyfinStart, + onSwap: onPutUp, + onDone: onPutUpDone, + onOpenIntegrations: onOpenIntegrations, + disabledHint: online ? null : 'Join or host to watch together', + ); + } +} diff --git a/apps/desktop/lib/src/shell/motion.dart b/apps/desktop/lib/src/shell/motion.dart new file mode 100644 index 0000000..bdd785f --- /dev/null +++ b/apps/desktop/lib/src/shell/motion.dart @@ -0,0 +1,126 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +// The shell's shared motion: one curve and one duration for everything +// that moves, a fade-through for views that replace each other, popovers +// that grow from where they open, and mouse-friendly horizontal lists. + +const ease = Curves.fastEaseInToSlowEaseOut; +const easeTime = Duration(milliseconds: 320); + +/// Fade-through length; the old view is gone after [fadeOutPart] of it. +const fadeThroughTime = Duration(milliseconds: 360); +const fadeOutPart = 0.35; + +/// Fade through (Material motion): the old view is gone after the first +/// 35%, then the new one fades and scales in; they never overlap. Key +/// [child] by what it shows so a change of key runs the transition. +Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher( + key: key, + duration: fadeThroughTime, + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [...previous, ?current], + ), + transitionBuilder: (c, anim) { + if (c.key == child.key) { + // Incoming (anim 0 -> 1): wait, then fade and scale up. + final a = CurvedAnimation( + parent: anim, + curve: const Interval(fadeOutPart, 1, curve: ease), + ); + return FadeTransition( + opacity: a, + child: ScaleTransition(scale: Tween(begin: 0.985, end: 1.0).animate(a), child: c), + ); + } + // Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65. + return FadeTransition( + opacity: CurvedAnimation( + parent: anim, + curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn), + ), + child: c, + ); + }, + child: child, + ); + +/// A card over the window that fades and scales in from [at] (window +/// alignment) instead of popping; a click outside or Esc closes it. Short +/// windows: the card scrolls instead of overflowing. +Future showPopover( + BuildContext context, { + required Alignment at, + required Widget child, + bool dim = false, +}) => + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'close', + barrierColor: dim ? Colors.black26 : Colors.transparent, + transitionDuration: easeTime, + pageBuilder: (context, _, _) => SafeArea( + child: Align( + alignment: at, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height - 32), + child: SingleChildScrollView(child: child), + ), + ), + ), + transitionBuilder: (context, anim, _, child) { + final a = CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn); + return FadeTransition( + opacity: a, + child: ScaleTransition( + scale: Tween(begin: 0.96, end: 1.0).animate(a), + alignment: at, + child: child, + ), + ); + }, + ); + +/// Horizontal lists for mouse users: drag with the mouse, and the plain +/// (vertical) wheel moves them sideways. At either end the wheel is left +/// to the page, so a row never traps vertical scrolling. +class HScroll extends StatefulWidget { + final ScrollController? controller; + final Widget Function(ScrollController controller) builder; + const HScroll({super.key, this.controller, required this.builder}); + + @override + State createState() => _HScrollState(); +} + +class _HScrollState extends State { + ScrollController? _own; + ScrollController get _c => widget.controller ?? (_own ??= ScrollController()); + + @override + void dispose() { + _own?.dispose(); + super.dispose(); + } + + void _wheel(PointerSignalEvent e) { + if (e is! PointerScrollEvent) return; + final dy = e.scrollDelta.dy; + if (dy == 0 || e.scrollDelta.dx != 0 || !_c.hasClients) return; + final p = _c.position; + final target = (p.pixels + dy * 1.5).clamp(p.minScrollExtent, p.maxScrollExtent); + if (target == p.pixels) return; // at the end: let the page scroll + GestureBinding.instance.pointerSignalResolver.register(e, (_) { + _c.animateTo(target, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic); + }); + } + + @override + Widget build(BuildContext context) => ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(dragDevices: PointerDeviceKind.values.toSet()), + child: Listener(onPointerSignal: _wheel, child: widget.builder(_c)), + ); +} diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart new file mode 100644 index 0000000..c705167 --- /dev/null +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -0,0 +1,486 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../screens/demo/shell_demo.dart'; +import '../screens/home/update_indicator.dart'; +import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; +import '../state/session_state.dart'; +import '../update/update_controller.dart'; +import '../widgets/dialogs.dart'; +import '../widgets/window_buttons.dart'; +import 'avatar.dart'; +import 'motion.dart'; + +/// The shell's tabs, in strip order. +enum ShellTab { + library('Library'), + schedule('Schedule'), + settings('Settings'); + + final String label; + const ShellTab(this.label); +} + +const partyStripHeight = 60.0; + +/// Below this window width the strip shows 3 avatars and no server text, +/// so the tabs keep their room. +const stripCompactBelow = 1100.0; + +/// The member plays the current Jellyfin item through the host's relay. +bool viaHostOf(MemberView m, JellyfinRef? ref) => + ref != null && m.jf[ref.serverId] == false; + +/// The 60 px strip over everything: your avatar (profile), the tabs, and +/// the party: offline Join / Host, online the avatars (members card), +/// server and readiness, chat and Leave. Empty space drags the window. +class PartyStrip extends StatelessWidget { + final ShellTab tab; + final ValueChanged onTab; + final bool chatOpen; + final int unread; + final VoidCallback onChat; + final VoidCallback onJoin; + final VoidCallback onHost; + final VoidCallback onLeave; + final VoidCallback onProfile; + + /// Online with something up but the player closed: brings it back. + final VoidCallback? onShowPlayer; + final UpdateController? updates; + + /// Off in tests: the drag area and window buttons need window_manager. + final bool windowControls; + const PartyStrip({ + super.key, + required this.tab, + required this.onTab, + required this.chatOpen, + required this.unread, + required this.onChat, + required this.onJoin, + required this.onHost, + required this.onLeave, + required this.onProfile, + this.onShowPlayer, + this.updates, + this.windowControls = true, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final compact = MediaQuery.sizeOf(context).width < stripCompactBelow; + final row = SignalBuilder( + builder: (context) { + final session = SessionState.instance; + final coord = session.coordinator; + final online = session.route.value == AppRoute.session; + final self = online ? coord.selfMember.value : null; + return Row( + children: [ + const SizedBox(width: 16), + Tooltip( + message: 'Your profile', + child: InkWell( + key: const ValueKey('strip-profile'), + customBorder: const CircleBorder(), + onTap: onProfile, + child: StatusAvatar( + info: selfAvatarInfo( + ready: self?.ready ?? false, + host: online && coord.hostFile != null, + ), + size: 32, + ring: online, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: HScroll( + builder: (c) => SingleChildScrollView( + controller: c, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final t in ShellTab.values) + StripTab( + key: ValueKey('tab-${t.name}'), + label: t.label, + selected: t == tab, + compact: compact, + onTap: () => onTab(t), + ), + ], + ), + ), + ), + ), + ), + if (kDebugMode) + IconButton( + tooltip: 'Layout demo (debug builds only)', + icon: const Icon( + Icons.bug_report_outlined, + size: 20, + color: Colors.amber, + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ShellDemo()), + ), + ), + UpdateIndicator(controller: updates), + const SizedBox(width: 8), + if (online) ...[ + if (onShowPlayer != null) + IconButton( + key: const ValueKey('show-player'), + tooltip: 'Show the player', + onPressed: onShowPlayer, + icon: const Icon(Icons.smart_display_outlined, size: 20), + ), + PartyWatch( + builder: (context) => AvatarStack( + key: const ValueKey('party-avatars'), + members: partyAvatars(coord), + shown: compact ? 3 : 5, + onTap: () => showMembersCard(context), + ), + ), + const SizedBox(width: 10), + if (!compact) _ServerLabel(scheme: scheme), + const SizedBox(width: 6), + Badge( + isLabelVisible: unread > 0, + label: Text('$unread'), + child: IconButton( + key: const ValueKey('strip-chat'), + tooltip: 'Chat & activity', + isSelected: chatOpen, + onPressed: onChat, + icon: const Icon(Icons.chat_bubble_outline, size: 20), + selectedIcon: Icon( + Icons.chat_bubble, + size: 20, + color: scheme.primary, + ), + ), + ), + const SizedBox(width: 4), + OutlinedButton( + key: const ValueKey('strip-leave'), + onPressed: onLeave, + child: const Text('Leave'), + ), + ] else ...[ + if (!compact) + Text( + 'Offline', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(width: 12), + FilledButton.tonal( + key: const ValueKey('strip-join'), + onPressed: onJoin, + child: const Text('Join'), + ), + const SizedBox(width: 8), + FilledButton( + key: const ValueKey('strip-host'), + onPressed: onHost, + child: const Text('Host'), + ), + ], + const SizedBox(width: 8), + if (windowControls) ...[ + SizedBox( + height: 20, + child: VerticalDivider(width: 12, color: scheme.outlineVariant), + ), + const WindowButtons(), + ] else + const SizedBox(width: 8), + ], + ); + }, + ); + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerLowest, + border: Border( + bottom: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + ), + child: SizedBox( + height: partyStripHeight, + child: Stack( + children: [ + // Empty strip space drags the window; the controls on top take + // their own clicks. + if (windowControls) + const Positioned.fill( + child: DragToMoveArea(child: SizedBox.expand()), + ), + Positioned.fill(child: row), + ], + ), + ), + ); + } +} + +/// Server name and "ready n/m". +class _ServerLabel extends StatelessWidget { + final ColorScheme scheme; + const _ServerLabel({required this.scheme}); + + @override + Widget build(BuildContext context) => SignalBuilder( + builder: (context) { + final coord = SessionState.instance.coordinator; + final r = coord.serverState.value; + final members = r?.members ?? const []; + final ready = members.where((m) => m.ready).length; + return Column( + key: const ValueKey('server-label'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + serverName(), + style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1), + ), + Text( + r == null ? 'connecting…' : 'ready $ready/${members.length}', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + height: 1.2, + ), + ), + ], + ); + }, + ); +} + +/// "Your server" when hosting, else the address joined. +String serverName() { + final session = SessionState.instance; + if (session.coordinator.hostFile != null) return 'Your server'; + return session.lastForm.host; +} + +/// One strip tab: label and an underline that grows in when selected. +class StripTab extends StatelessWidget { + final String label; + final bool selected; + final bool compact; + final VoidCallback onTap; + const StripTab({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Semantics( + selected: selected, + button: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12), + child: SizedBox( + height: partyStripHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + AnimatedContainer( + duration: easeTime, + curve: ease, + height: 3, + width: selected ? 22 : 0, + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Who is in the server, from the avatars: status, host / relay pills, +/// and per member force ready and kick. +Future showMembersCard(BuildContext context) => showPopover( + context, + at: const Alignment(0.7, -0.84), + child: const MembersCard(), +); + +class MembersCard extends StatelessWidget { + const MembersCard({super.key}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + key: const ValueKey('members-card'), + elevation: 12, + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: 340, + child: PartyWatch( + builder: (context) { + final session = SessionState.instance; + final coord = session.coordinator; + final r = coord.serverState.value; + final hosting = coord.hostFile != null; + final address = hosting + ? null + : '${session.lastForm.host}:${session.lastForm.port}'; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + ['In the server', ?address].join(' · '), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + for (final m in r?.members ?? const []) + _MemberTile( + member: m, + info: memberAvatarInfo(m, r, coord), + viaHost: viaHostOf(m, coord.currentRef.value), + ), + if (r == null) + Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'connecting…', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), + if (address != null) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => + Clipboard.setData(ClipboardData(text: address)), + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy address'), + ), + ), + ), + ] else + const SizedBox(height: 8), + ], + ); + }, + ), + ), + ); + } +} + +class _MemberTile extends StatelessWidget { + final MemberView member; + final AvatarInfo info; + final bool viaHost; + const _MemberTile({ + required this.member, + required this.info, + required this.viaHost, + }); + + @override + Widget build(BuildContext context) { + final coord = SessionState.instance.coordinator; + final m = member; + final status = m.stale + ? 'reconnecting…' + : info.buffering + ? 'buffering ${m.buffered.toStringAsFixed(1)} s' + : m.ready + ? 'ready' + : 'not ready'; + return ListTile( + key: ValueKey('member-${m.id}'), + dense: true, + contentPadding: const EdgeInsets.only(left: 20, right: 8), + leading: StatusAvatar(info: info, size: 36, badges: false), + title: Wrap( + spacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(info.self ? '${m.name} (you)' : m.name), + if (info.host) const Pill('host', Colors.amber), + if (info.relaying) + const Pill('passing the video on', Colors.lightBlueAccent), + if (viaHost) const ViaHostBadge(), + ], + ), + subtitle: Text(status), + trailing: PopupMenuButton( + tooltip: 'More', + icon: const Icon(Icons.more_vert, size: 18), + onSelected: (v) async { + if (v == 'force') { + coord.forceTarget(m.id, !m.ready); + } else if (v == 'kick') { + final ok = await showKickConfirmDialog(context, m.name); + if (ok == true) coord.kick(m.id); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'force', + child: Text(m.ready ? 'Force unready' : 'Force ready'), + ), + if (!info.self) + PopupMenuItem( + value: 'kick', + child: Text( + 'Kick', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/shell/profile_card.dart b/apps/desktop/lib/src/shell/profile_card.dart new file mode 100644 index 0000000..5fc0eba --- /dev/null +++ b/apps/desktop/lib/src/shell/profile_card.dart @@ -0,0 +1,550 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../jellyfin/accounts.dart'; +import '../screens/home/profile.dart'; +import '../state/session_state.dart'; +import '../update/update_controller.dart'; +import 'avatar.dart'; +import 'motion.dart'; +import 'settings_tab.dart' show SettingsSection; + +/// Your profile, from your avatar in the strip: name and picture apply +/// live (pushed to the server while in one), colour is yours only; links +/// to the app-wide pages. +Future showProfileCard( + BuildContext context, { + required ValueChanged onOpenSettings, + UpdateController? updates, +}) => + showPopover( + context, + at: const Alignment(-0.97, -0.84), + child: ProfileCard(onOpenSettings: onOpenSettings, updates: updates), + ); + +class ProfileCard extends StatefulWidget { + final ValueChanged onOpenSettings; + final UpdateController? updates; + const ProfileCard({super.key, required this.onOpenSettings, this.updates}); + + @override + State createState() => _ProfileCardState(); +} + +class _ProfileCardState extends State { + final _p = Profile.instance; + late final _name = TextEditingController(text: _p.name.peek()); + bool _hoverPic = false; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + void _open(SettingsSection s) { + Navigator.of(context).maybePop(); + widget.onOpenSettings(s); + } + + Future _changePicture() => showPopover( + context, + at: Alignment.center, + dim: true, + child: CropPicker(onUse: _p.setPicture), + ); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final small = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12); + return Material( + elevation: 12, + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: 360, + child: SignalBuilder(builder: (context) { + final session = SessionState.instance; + final coord = session.coordinator; + final online = session.route.value == AppRoute.session; + final self = online ? coord.selfMember.value : null; + final hosting = online && coord.hostFile != null; + final color = _p.color.value; + final png = _p.picture.value; + final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header tinted with your colour. + AnimatedContainer( + duration: easeTime, + curve: ease, + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0.08)], + ), + ), + child: Row( + children: [ + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoverPic = true), + onExit: (_) => setState(() => _hoverPic = false), + child: GestureDetector( + key: const ValueKey('profile-picture'), + onTap: _changePicture, + child: SizedBox( + width: 88, + height: 88, + child: Stack( + children: [ + StatusAvatar(info: me, size: 88, badges: false, ring: online), + Positioned.fill( + child: AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: _hoverPic ? 1 : 0, + child: Container( + margin: const EdgeInsets.all(4), + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black54), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.photo_camera_outlined, color: Colors.white, size: 22), + Text('Change', style: TextStyle(color: Colors.white, fontSize: 11)), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const ValueKey('profile-name'), + controller: _name, + maxLength: 32, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + decoration: const InputDecoration( + isDense: true, + counterText: '', + border: InputBorder.none, + hintText: 'Your name', + suffixIcon: Icon(Icons.edit_outlined, size: 16), + suffixIconConstraints: BoxConstraints(minWidth: 20), + ), + onChanged: _p.setName, + onSubmitted: (_) => Navigator.of(context).maybePop(), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (hosting) const Pill('host', Colors.amber), + Pill(online ? 'in a server' : 'offline', + online ? Colors.greenAccent : Colors.grey), + if (online) + Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent), + ], + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 4), + child: Row( + children: [ + Text('Colour', style: small), + const Spacer(), + for (final c in profileColors) + Padding( + padding: const EdgeInsets.only(left: 8), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _p.setColor(c), + child: AnimatedContainer( + duration: easeTime, + curve: ease, + width: 24, + height: 24, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: color.toARGB32() == c.toARGB32() ? scheme.onSurface : Colors.transparent, + width: 2, + ), + ), + ), + ), + ), + ], + ), + ), + if (png != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 8, 0), + child: Row( + children: [ + Icon(Icons.image_outlined, size: 16, color: scheme.onSurfaceVariant), + const SizedBox(width: 6), + Text('PNG · ${(png.length / 1024).toStringAsFixed(0)} KB', style: small), + const Spacer(), + TextButton(onPressed: () => _p.setPicture(null), child: const Text('Remove picture')), + ], + ), + ), + if (_p.error.value case final err?) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 0), + child: Text(err, style: TextStyle(color: scheme.error, fontSize: 12)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 0), + child: Text( + online + ? 'Name and picture update for everyone in the server right away. Colour is yours only.' + : 'Others see your name and picture when you join. Colour is yours only.', + style: small, + ), + ), + const Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 0), child: Divider(height: 1)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column(children: [ + _link( + Icons.extension_outlined, + 'Jellyfin & integrations', + trailing: ListenableBuilder( + listenable: JellyfinAccounts.instance, + builder: (context, _) { + final n = JellyfinAccounts.instance.accounts.length; + return Text(n == 0 ? 'none' : '$n linked', style: small); + }, + ), + onTap: () => _open(SettingsSection.integrations), + ), + _link(Icons.settings_outlined, 'Settings', onTap: () => _open(SettingsSection.profile)), + ValueListenableBuilder( + valueListenable: (widget.updates ?? UpdateController.instance).status, + builder: (context, s, _) { + final m = s.manifest; + final available = s.phase != UpdatePhase.idle && m != null; + return _link( + Icons.system_update_alt, + available ? 'Update to ${m.version}' : 'Updates', + trailing: available ? Pill('new', Colors.greenAccent.shade400) : null, + onTap: () => _open(SettingsSection.updates), + ); + }, + ), + _link(Icons.info_outline, 'About', onTap: () => _open(SettingsSection.about)), + ]), + ), + ], + ); + }), + ), + ); + } + + Widget _link(IconData icon, String label, {Widget? trailing, required VoidCallback onTap}) => ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + leading: Icon(icon, size: 20), + title: Text(label), + trailing: trailing, + onTap: onTap, + ); +} + +/// Avatar crop: pick a file, drag to move, scroll or slide to zoom; live +/// previews at the sizes it is shown. "Use this" renders a 256 px PNG. +class CropPicker extends StatefulWidget { + final Future Function(Uint8List png) onUse; + + /// Opens the file dialog right away (that is why the picker opened). + final bool autoPick; + const CropPicker({super.key, required this.onUse, this.autoPick = true}); + + @override + State createState() => _CropPickerState(); +} + +class _CropPickerState extends State { + static const _view = 300.0, _out = 256; + ui.Image? _img; + double _zoom = 1; // 1 = the image just covers the circle + Offset _pan = Offset.zero; // in viewport pixels + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + if (widget.autoPick) WidgetsBinding.instance.addPostFrameCallback((_) => _pick()); + } + + @override + void dispose() { + _img?.dispose(); + super.dispose(); + } + + Future _pick() async { + final List files; + try { + files = await FilePicker.pickFiles( + dialogTitle: 'Pick a picture', + type: FileType.custom, + allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'], + ); + } catch (e) { + if (mounted) setState(() => _error = 'file dialog unavailable — try again'); + return; + } + final path = files.isEmpty ? null : files.first.path; + if (path == null) return; + try { + final codec = await ui.instantiateImageCodec(await File(path).readAsBytes()); + final frame = await codec.getNextFrame(); + if (!mounted) return; + _img?.dispose(); + setState(() { + _img = frame.image; + _zoom = 1; + _pan = Offset.zero; + _error = null; + }); + } catch (e) { + if (mounted) setState(() => _error = 'could not read that image'); + } + } + + double get _scale { + final img = _img!; + return math.max(_view / img.width, _view / img.height) * _zoom; + } + + /// Keeps the circle covered: the image edge never shows inside it. + Offset _clamp(Offset p) { + final img = _img!; + final mx = (img.width * _scale - _view) / 2, my = (img.height * _scale - _view) / 2; + return Offset(p.dx.clamp(-mx, mx), p.dy.clamp(-my, my)); + } + + void _setZoom(double z) => setState(() { + _zoom = z.clamp(1.0, 6.0); + _pan = _clamp(_pan); + }); + + Future _use() async { + final img = _img; + if (img == null) return; + setState(() => _busy = true); + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + canvas.scale(_out / _view); + CropPainter(img, _scale, _pan).paint(canvas, const Size(_view, _view)); + final out = await rec.endRecording().toImage(_out, _out); + final png = await out.toByteData(format: ui.ImageByteFormat.png); + out.dispose(); + if (!mounted || png == null) return; + await widget.onUse(png.buffer.asUint8List()); + if (mounted) Navigator.of(context).maybePop(); + } + + Widget _preview(double size) => ClipOval( + child: SizedBox( + width: size, + height: size, + child: CustomPaint(painter: CropPainter(_img!, _scale * size / _view, _pan * (size / _view))), + ), + ); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final img = _img; + const caption = TextStyle(fontSize: 11, color: Colors.grey); + final editor = img == null + ? InkWell( + customBorder: const CircleBorder(), + onTap: _pick, + child: Container( + width: _view, + height: _view, + decoration: BoxDecoration(shape: BoxShape.circle, color: scheme.surfaceContainerHighest), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_photo_alternate_outlined, size: 48, color: scheme.onSurfaceVariant), + const SizedBox(height: 8), + Text(_error ?? 'Choose a picture', style: TextStyle(color: scheme.onSurfaceVariant)), + ], + ), + ), + ) + : Listener( + onPointerSignal: (e) { + if (e is PointerScrollEvent) _setZoom(_zoom * (e.scrollDelta.dy > 0 ? 0.93 : 1.07)); + }, + child: GestureDetector( + onPanUpdate: (d) => setState(() => _pan = _clamp(_pan + d.delta)), + child: MouseRegion( + cursor: SystemMouseCursors.move, + // The dimmed surroundings stay inside this rounded square. + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: SizedBox( + width: _view, + height: _view, + child: Stack( + children: [ + // The whole image, dimmed outside the circle. + Positioned.fill( + child: Opacity( + opacity: 0.3, + child: CustomPaint(painter: CropPainter(img, _scale, _pan, clipToView: false)), + ), + ), + ClipOval( + child: CustomPaint(size: const Size(_view, _view), painter: CropPainter(img, _scale, _pan)), + ), + IgnorePointer( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: scheme.primary, width: 2), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + return Material( + elevation: 16, + borderRadius: BorderRadius.circular(18), + child: SizedBox( + width: 560, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Your picture', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + img == null ? 'PNG, JPG, WebP, GIF or BMP' : 'Drag to move · scroll to zoom', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + editor, + if (img != null) ...[ + const SizedBox(width: 24), + Column( + children: [ + _preview(88), + const SizedBox(height: 4), + const Text('profile', style: caption), + const SizedBox(height: 14), + _preview(42), + const SizedBox(height: 4), + const Text('party', style: caption), + const SizedBox(height: 14), + _preview(24), + const SizedBox(height: 4), + const Text('chat', style: caption), + ], + ), + ], + ], + ), + if (img != null) + Row( + children: [ + const Icon(Icons.zoom_out, size: 18), + Expanded(child: Slider(value: _zoom, min: 1, max: 6, onChanged: _setZoom)), + const Icon(Icons.zoom_in, size: 18), + ], + ) + else + const SizedBox(height: 16), + Row( + children: [ + TextButton.icon( + onPressed: _pick, + icon: const Icon(Icons.folder_open, size: 18), + label: Text(img == null ? 'Choose…' : 'Other file…'), + ), + const Spacer(), + TextButton(onPressed: () => Navigator.of(context).maybePop(), child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton(onPressed: img == null || _busy ? null : _use, child: const Text('Use this')), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Draws [img] scaled by [scale], centred in the box and moved by [pan]. +class CropPainter extends CustomPainter { + final ui.Image img; + final double scale; + final Offset pan; + final bool clipToView; + CropPainter(this.img, this.scale, this.pan, {this.clipToView = true}); + + @override + void paint(Canvas canvas, Size size) { + if (clipToView) canvas.clipRect(Offset.zero & size); + final w = img.width * scale, h = img.height * scale; + final dst = Rect.fromLTWH((size.width - w) / 2 + pan.dx, (size.height - h) / 2 + pan.dy, w, h); + canvas.drawImageRect( + img, + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()), + dst, + Paint()..filterQuality = FilterQuality.high, + ); + } + + @override + bool shouldRepaint(CropPainter old) => old.img != img || old.scale != scale || old.pan != pan; +} diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart new file mode 100644 index 0000000..9269c6e --- /dev/null +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -0,0 +1,1048 @@ +import 'package:flutter/gestures.dart' show kPrimaryMouseButton; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../controls/uosc/buttons.dart'; +import '../controls/uosc/controls.dart'; +import '../controls/uosc/timeline.dart'; +import '../playback/media_marks.dart' show SkipOffer, formatClock; +import '../screens/session/jellyfin_widgets.dart'; +import '../screens/session/player_controls.dart'; +import '../session/health.dart'; +import '../session/session_coordinator.dart'; +import '../shaders/shader_menu.dart'; +import '../shaders/shader_presets.dart'; +import '../state/session_state.dart'; +import '../state/settings.dart'; +import '../widgets/shown_frame_probe.dart'; +import '../widgets/text_focus.dart'; +import 'avatar.dart'; +import 'motion.dart'; + +/// Where the session's video is shown: nowhere, the floating mini player, +/// or filling the body (and, with fullscreen, the window). +enum PlayerMode { none, mini, full } + +/// Builds the one video view of the current player around [controls] +/// (null: none, the mini player). Tests pass a stand-in: media_kit's +/// `Video` hangs flutter_test. +typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls); + +/// The session's player: the one `Video` of the current player and +/// everything over it. Full: our bars on media_kit's controls or the uosc +/// set (Settings), health / waiting / Jellyfin overlays and the player +/// keys. Mini: hover controls and a progress line. The shell keeps this +/// widget mounted at one place in the tree while the mode changes, so the +/// `Video` is never mounted twice nor rebuilt for a move. +class SessionPlayer extends StatefulWidget { + final PlayerMode mode; + final bool fullscreen; + final bool chatOpen; + final int unread; + final VoidCallback onExpand; + final VoidCallback onCollapse; + final VoidCallback onClose; + final VoidCallback onToggleFullscreen; + final VoidCallback onToggleChat; + final VoidCallback onChangeVideo; + final VoidCallback onOpenIntegrations; + /// Asks before leaving the server. + final VoidCallback onLeave; + final VideoViewBuilder? videoBuilder; + const SessionPlayer({ + super.key, + required this.mode, + required this.fullscreen, + required this.onExpand, + required this.onCollapse, + required this.onClose, + required this.onToggleFullscreen, + required this.onToggleChat, + required this.onChangeVideo, + required this.onOpenIntegrations, + required this.onLeave, + this.chatOpen = false, + this.unread = 0, + this.videoBuilder, + }); + + @override + State createState() => _SessionPlayerState(); +} + +class _SessionPlayerState extends State { + // One key per player generation: a reloaded player needs a fresh + // `Video` (it never swaps controllers in place). + var _videoGen = -1; + var _videoKey = GlobalKey(); + final _frameProbe = ShownFrameProbe(); + final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); + DateTime? _lastKeyAt; + late final void Function() _stopShaderWatch; + bool _shaderMenuOpen = false; + // Own toast ids, negative: the coordinator counts up from 1. + int _toastSeq = 0; + int _shaderNoticeSeen = 0; + bool _miniHover = false; + + SessionCoordinator get _coord => SessionState.instance.coordinator; + bool get _full => widget.mode == PlayerMode.full; + + @override + void initState() { + super.initState(); + _coord.jellyfin.ensureLoaded(); + _coord.playback.shownFrame = _frameProbe.sample; + HardwareKeyboard.instance.addHandler(_globalKey); + // Shader messages (preset changes, load failures) become toasts. + _shaderNoticeSeen = _coord.playback.shaderNotice.peek()?.id ?? 0; + _stopShaderWatch = effect(() { + final n = _coord.playback.shaderNotice.value; + if (n == null || n.id == _shaderNoticeSeen) return; + _shaderNoticeSeen = n.id; + _toast(n.text); + }); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_globalKey); + final playback = _coord.playback; + if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; + _stopShaderWatch(); + _noFocus.dispose(); + super.dispose(); + } + + void _reloadVideo() { + _coord.markInput(); + _coord.reloadVideo(); + } + + void _toast(String text) { + _coord.toasts.value = [ + ..._coord.toasts.peek(), + ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()), + ]; + } + + /// Menu, Ctrl+0..6: saved as the choice (also used on the next join) + /// and applied live. A null quality keeps the saved one. + void _pickShader(ShaderPreset preset, ShaderQuality? quality) { + final s = AppSettings.instance; + if (quality != null) s.setShaderQuality(quality); + s.setShaderPreset(preset); + _coord.markInput(); + _coord.playback.applyShaders(announce: true); + } + + void _toggleChat() { + _coord.markInput(); + widget.onToggleChat(); + } + + /// The player keys, while the player fills the body and nothing (a + /// dialog, a card, a text field) is in front of it. + bool _globalKey(KeyEvent event) { + if (event is! KeyDownEvent || !mounted || !_full) return false; + if (SessionState.instance.route.peek() != AppRoute.session) return false; + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) { + // A menu of ours is open: Esc closes it. + if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context, rootNavigator: true).maybePop(); + return true; + } + return false; + } + if (textInputFocused()) return false; + final now = DateTime.now().toUtc(); + if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { + return false; + } + final coord = _coord; + final key = event.logicalKey; + // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). + final digit = _shaderKeys.indexOf(key); + if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { + _lastKeyAt = now; + _pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null); + return true; + } + if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) { + _lastKeyAt = now; + _reloadVideo(); + return true; + } + if (key == LogicalKeyboardKey.space) { + _lastKeyAt = now; + coord.toggleReady(); + return true; + } + // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. + final shift = HardwareKeyboard.instance.isShiftPressed; + if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { + if (coord.nextEpisode.peek() == null) return false; + _lastKeyAt = now; + coord.playNextEpisode(); + return true; + } + if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) { + if (coord.previousEpisode.peek() == null) return false; + _lastKeyAt = now; + coord.playPreviousEpisode(); + return true; + } + // mpv's chapter keys. + if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) { + _lastKeyAt = now; + return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1); + } + if (shift && key == LogicalKeyboardKey.arrowRight) { + _lastKeyAt = now; + coord.skipForward(); + return true; + } + if (key == LogicalKeyboardKey.tab) { + _lastKeyAt = now; + _toggleChat(); + return true; + } + if (key == LogicalKeyboardKey.arrowRight) { + _lastKeyAt = now; + coord.nudge(10); + return true; + } + if (key == LogicalKeyboardKey.arrowLeft) { + _lastKeyAt = now; + coord.nudge(-10); + return true; + } + if (key == LogicalKeyboardKey.escape) { + _lastKeyAt = now; + if (widget.chatOpen) { + widget.onToggleChat(); + } else if (widget.fullscreen) { + widget.onToggleFullscreen(); + } else { + widget.onLeave(); + } + return true; + } + if (key == LogicalKeyboardKey.keyF) { + _lastKeyAt = now; + widget.onToggleFullscreen(); + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + return FocusTraversalGroup( + descendantsAreTraversable: false, + child: ColoredBox( + color: Colors.black, + // Player lifetime = session (and each "Reload video"): no live + // player ⇒ no `Video`, so no texture outlives its player. + child: SignalBuilder(builder: (context) { + final playback = _coord.playback; + final gen = playback.generation.value; + if (!playback.alive.value) { + return Center( + child: Text('player starting…', + style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)), + ); + } + if (gen != _videoGen) { + _videoGen = gen; + _videoKey = GlobalKey(); + } + final VideoControlsBuilder? controls = _full ? _sessionControls : null; + final video = widget.videoBuilder?.call(context, controls) ?? _video(controls); + return Stack( + fit: StackFit.expand, + children: [ + // The frozen-picture watchdog captures this boundary: the + // video (and its controls), not the window around it. + RepaintBoundary( + child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]), + ), + if (_full) ...[ + _healthOverlay(), + _waitingPill(bottom: 124), + _jellyfinOverlay(), + if (widget.fullscreen) _pausedParty(), + ] else + _miniOverlay(), + ], + ); + }), + ), + ); + } + + Widget _video(VideoControlsBuilder? controls) { + final theme = _controlsTheme(); + return MaterialDesktopVideoControlsTheme( + normal: theme, + fullscreen: theme, + child: Video( + key: _videoKey, + controller: _coord.playback.video, + subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), + controls: controls, + ), + ); + } + + static const _barHeight = 56.0; + + String get _title { + final ref = _coord.currentRef.value; + if (ref != null && ref.title.isNotEmpty) { + return [ref.title, ?ref.subtitle].join(' · '); + } + return (_coord.serverState.value?.media?['name'] as String?) ?? ''; + } + + MaterialDesktopVideoControlsThemeData _controlsTheme() { + final coord = _coord; + return MaterialDesktopVideoControlsThemeData( + playAndPauseOnTap: false, + modifyVolumeOnScroll: false, + // media_kit's double-click pushes its own fullscreen route (a second + // `Video`); ours toggles the window instead (see [DoubleClickListener]). + toggleFullscreenOnDoublePress: false, + keyboardShortcuts: {}, + visibleOnMount: true, + hideMouseOnControlsRemoval: true, + controlsHoverDuration: const Duration(seconds: 3), + // Our seek bar (chapter marks, hover title) sits in the bottom bar, + // which grows by its height; the top bar keeps its row at the top. + displaySeekBar: false, + buttonBarHeight: _barHeight + ChapterSeekBar.height, + topButtonBar: [ + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: _barHeight, + child: Row( + children: [ + Expanded( + child: SignalBuilder( + builder: (context) => Text( + _title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16), + ), + ), + ), + MaterialDesktopCustomButton( + icon: const Icon(Icons.video_library_outlined), + onPressed: widget.onChangeVideo, + ), + MaterialDesktopCustomButton( + icon: Badge( + isLabelVisible: widget.unread > 0, + label: Text('${widget.unread}'), + child: Icon(widget.chatOpen ? Icons.chat_bubble : Icons.chat_bubble_outline), + ), + onPressed: _toggleChat, + ), + MaterialDesktopCustomButton( + icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen), + onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, + ), + ], + ), + ), + ), + ), + ], + bottomButtonBar: [ + Expanded( + child: Column( + children: [ + SignalBuilder( + builder: (context) => PlayerSeekBar( + player: coord.playback.player, + chapters: coord.playback.chapters.value, + ), + ), + Expanded(child: Row(children: _bottomButtons())), + ], + ), + ), + ], + ); + } + + List _bottomButtons() { + final coord = _coord; + final playback = coord.playback; + void menuOpen(bool open) => _shaderMenuOpen = open; + void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff); + return [ + SignalBuilder( + builder: (context) => EpisodeSkipButton( + focusNode: _noFocus, + episode: coord.previousEpisode.value, + next: false, + onPressed: coord.playPreviousEpisode, + ), + ), + SignalBuilder( + builder: (context) => EpisodeSkipButton( + focusNode: _noFocus, + episode: coord.nextEpisode.value, + next: true, + onPressed: coord.playNextEpisode, + ), + ), + const MaterialDesktopVolumeButton(), + _ReadyControlButton(focusNode: _noFocus), + _BarTextButton( + focusNode: _noFocus, + label: 'Mark everyone ready', + onPressed: coord.forcePlayAll, + ), + _BarTextButton( + focusNode: _noFocus, + icon: Icons.refresh, + label: 'Reload video', + tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture', + onPressed: _reloadVideo, + ), + const MaterialDesktopPositionIndicator(), + const Spacer(), + SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward), + SignalBuilder( + builder: (context) => ChaptersControlButton( + focusNode: _noFocus, + chapters: playback.chapters.value, + current: playback.currentChapter.value, + onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), + onMenuOpen: menuOpen, + ), + ), + SignalBuilder( + builder: (context) => TrackMenuButton( + focusNode: _noFocus, + type: JellyfinStreamType.audio, + tracks: playback.tracks.value, + onPick: pickTrack, + onMenuOpen: menuOpen, + ), + ), + SignalBuilder( + builder: (context) => TrackMenuButton( + focusNode: _noFocus, + type: JellyfinStreamType.subtitle, + tracks: playback.tracks.value, + onPick: pickTrack, + onMenuOpen: menuOpen, + ), + ), + ShaderControlButton( + focusNode: _noFocus, + renderMode: playback.renderMode, + onPick: _pickShader, + onMenuOpen: menuOpen, + ), + MaterialDesktopCustomButton( + icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen), + onPressed: widget.onToggleFullscreen, + ), + ]; + } + + Widget _sessionControls(VideoState state) { + // Settings → "Experimental: mpv-style controls" swaps them live. + return SessionControlsSwitch( + material: (_) => DoubleClickListener( + onDoubleClick: widget.onToggleFullscreen, + // Clicks on the bars are button clicks, not the video. + skipTop: _barHeight, + skipBottom: _barHeight + ChapterSeekBar.height, + child: MaterialDesktopVideoControls(state), + ), + mpv: (_) => _mpvControls(), + ); + } + + /// The experimental uosc-style controls: same actions as the bars above, + /// all through the coordinator (seeks via `player.seek`, the user path). + Widget _mpvControls() { + final coord = _coord; + final playback = coord.playback; + void menuOpen(bool open) => _shaderMenuOpen = open; + void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff); + Widget episode(bool next) => SignalBuilder(builder: (context) { + final e = next ? coord.nextEpisode.value : coord.previousEpisode.value; + if (e == null) return const SizedBox.shrink(); + return UoscButton( + key: ValueKey(next ? 'episode-next' : 'episode-previous'), + icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded, + tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})', + onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode, + ); + }); + return UoscControls( + onDoubleTap: widget.onToggleFullscreen, + top: Row( + mainAxisSize: MainAxisSize.min, + children: [ + UoscButton(icon: Icons.chat_bubble_outline_rounded, tooltip: 'Chat & activity (Tab)', onPressed: _toggleChat), + UoscButton( + icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded, + tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', + onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, + ), + ], + ), + timeline: (context, expanded) => SignalBuilder( + builder: (context) => UoscTimeline( + position: playback.position.value, + duration: playback.duration.value, + buffered: playback.buffered.value, + chapters: playback.chapters.value, + expanded: expanded, + onSeek: (d) => playback.player.seek(d), + ), + ), + left: [ + UoscMainMenuButton( + onMenuOpen: menuOpen, + actions: [ + (title: 'Change video', hint: null, run: widget.onChangeVideo), + (title: 'Chat & activity', hint: 'Tab', run: _toggleChat), + (title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo), + (title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll), + (title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen), + (title: 'Leave server', hint: 'Esc', run: widget.onLeave), + ], + ), + SignalBuilder( + builder: (context) => UoscTracksButton( + type: JellyfinStreamType.subtitle, + tracks: playback.tracks.value, + onPick: pickTrack, + onMenuOpen: menuOpen, + ), + ), + SignalBuilder( + builder: (context) => UoscTracksButton( + type: JellyfinStreamType.audio, + tracks: playback.tracks.value, + onPick: pickTrack, + onMenuOpen: menuOpen, + ), + ), + UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen), + const SizedBox(width: 12), + SignalBuilder(builder: (context) { + final ready = coord.selfMember.value?.ready ?? false; + return UoscButton( + key: const ValueKey('uosc-ready'), + icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, + label: ready ? 'Ready' : 'Not ready', + color: ready ? Colors.greenAccent : null, + tooltip: 'Ready / not ready (Space)', + onPressed: coord.toggleReady, + ); + }), + UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll), + UoscButton( + icon: Icons.refresh_rounded, + tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture', + onPressed: _reloadVideo, + ), + ], + right: [ + UoscButton( + key: const ValueKey('uosc-skip-forward'), + icon: Icons.fast_forward_rounded, + label: '1:30', + tooltip: 'Skip 1:30 for everyone (Shift+Right)', + onPressed: coord.skipForward, + ), + SignalBuilder( + builder: (context) => UoscChaptersButton( + chapters: playback.chapters.value, + current: playback.currentChapter.value, + duration: playback.duration.value, + onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6), + onMenuOpen: menuOpen, + ), + ), + episode(false), + episode(true), + const SizedBox(width: 12), + UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo), + StreamBuilder( + stream: playback.player.stream.volume, + initialData: playback.player.state.volume, + builder: (context, v) => UoscVolumeButton( + volume: v.data ?? 100, + onChanged: (x) => playback.player.setVolume(x), + ), + ), + UoscButton( + icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, + tooltip: 'Fullscreen (F)', + onPressed: widget.onToggleFullscreen, + ), + ], + ); + } + + Widget _healthOverlay() { + final coord = _coord; + return Positioned( + top: _barHeight + 8, + left: 12, + right: 12, + child: Align( + alignment: Alignment.topLeft, + child: SignalBuilder( + builder: (context) { + final h = coord.health.value; + final starve = coord.starving.value; + final retrying = coord.reconnecting.value; + final kicked = coord.kickReason.value; + final unwell = h.severity == HealthSeverity.warn || + h.severity == HealthSeverity.error || + retrying || + starve != null; + if (!unwell) return const SizedBox.shrink(); + final color = switch (h.severity) { + HealthSeverity.ok => Colors.green, + HealthSeverity.info => Colors.grey, + HealthSeverity.warn => Colors.orange, + HealthSeverity.error => Colors.red, + }; + final IconData icon; + if (retrying) { + icon = Icons.sync; + } else if (h.severity == HealthSeverity.error) { + icon = Icons.error_outline; + } else if (h.severity == HealthSeverity.warn) { + icon = Icons.warning_amber_outlined; + } else { + icon = Icons.hourglass_empty; + } + final text = [h.message, ?starve, if (retrying) 'reconnecting…'].join(' · '); + final canRetry = !retrying && + kicked == null && + (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn); + final button = TextButton.styleFrom( + foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap); + return Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 8), + Flexible(child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12))), + if (retrying) + TextButton( + style: button, + onPressed: coord.cancelReconnect, + child: const Text('Cancel', style: TextStyle(fontSize: 12)), + ), + if (canRetry) + TextButton( + style: button, + onPressed: coord.retry, + child: const Text('Retry', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _waitingPill({required double bottom}) => + Positioned(left: 0, right: 0, bottom: bottom, child: const Center(child: WaitingPill())); + + /// Jellyfin: the blocked card, the end-of-episode card (countdown) and + /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / + /// 1:30) above it. Outside the auto-hiding controls, so they stay up. + Widget _jellyfinOverlay() { + final coord = _coord; + return Positioned.fill( + child: SignalBuilder( + builder: (context) { + final blocked = coord.jellyfinBlocked.value; + final next = coord.nextEpisode.value; + final countdown = coord.autoAdvance.value; + final finished = coord.serverState.value?.transport == 'finished'; + final pill = coord.nextPromptVisible.value; + final SkipOffer? skip = coord.skipOffer.value; + final showNext = next != null && pill; + return Stack( + children: [ + if (blocked != null) + Center( + child: JellyfinBlockedCard( + blocked: blocked, + onLinkAccount: widget.onOpenIntegrations, + onRetry: coord.retryJellyfin, + ), + ), + if (next != null && (finished || countdown != null)) + Positioned( + right: 24, + bottom: 138, + child: EndOfEpisodeCard( + next: next, + countdown: countdown, + onPlayNow: coord.playNextEpisode, + onCancel: coord.cancelAutoAdvance, + ), + ) + else if (showNext || skip != null) + Positioned( + right: 24, + bottom: 124, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 10, + children: [ + if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)), + if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), + ], + ), + ), + ], + ); + }, + ), + ); + } + + /// Fullscreen hides the strip: while paused the party stays in a corner. + Widget _pausedParty() => Positioned( + right: 16, + top: _barHeight + 8, + child: PartyWatch(builder: (context) { + final r = _coord.serverState.value; + final paused = r != null && r.transport != 'playing'; + return AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: paused ? 1 : 0, + child: IgnorePointer(child: AvatarStack(members: partyAvatars(_coord))), + ); + }), + ); + + /// Mini: title, expand and close on hover; ready, time and a seekable + /// bar at the bottom. A thin progress line stays while not hovered. + Widget _miniOverlay() { + final coord = _coord; + final playback = coord.playback; + const white = TextStyle(color: Colors.white, fontWeight: FontWeight.w600); + return MouseRegion( + onEnter: (_) => setState(() => _miniHover = true), + onExit: (_) => setState(() => _miniHover = false), + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 160), + opacity: _miniHover ? 1 : 0, + child: IgnorePointer( + ignoring: !_miniHover, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent, Colors.black87], + ), + ), + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 10), + Expanded( + child: SignalBuilder( + builder: (context) => + Text(_title, maxLines: 1, overflow: TextOverflow.ellipsis, style: white), + ), + ), + IconButton( + focusNode: _noFocus, + onPressed: widget.onExpand, + tooltip: 'Expand', + icon: const Icon(Icons.open_in_full, color: Colors.white, size: 18), + ), + IconButton( + focusNode: _noFocus, + onPressed: widget.onClose, + tooltip: 'Stop watching (stay in the server)', + icon: const Icon(Icons.close, color: Colors.white, size: 18), + ), + ], + ), + const Spacer(), + SignalBuilder(builder: (context) { + final ready = coord.selfMember.value?.ready ?? false; + final pos = playback.position.value, dur = playback.duration.value; + return Row( + children: [ + IconButton( + focusNode: _noFocus, + tooltip: ready ? 'Ready (click: not ready)' : 'Not ready (click: ready)', + onPressed: coord.toggleReady, + icon: Icon( + ready ? Icons.check_circle : Icons.radio_button_unchecked, + color: ready ? Colors.greenAccent : Colors.white, + ), + ), + Text('${formatClock(pos)} / ${formatClock(dur)}', + style: const TextStyle(color: Colors.white70, fontSize: 12)), + const Spacer(), + Flexible( + child: Text(waitingText(compact: true), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.amber, fontSize: 12)), + ), + const SizedBox(width: 10), + ], + ); + }), + _MiniSeekBar(onSeek: (d) => playback.player.seek(d)), + ], + ), + ), + ), + ), + if (!_miniHover) + const Positioned(left: 0, right: 0, bottom: 0, child: _MiniSeekBar(thin: true)), + ], + ), + ); + } +} + +const _shaderKeys = [ + LogicalKeyboardKey.digit0, + LogicalKeyboardKey.digit1, + LogicalKeyboardKey.digit2, + LogicalKeyboardKey.digit3, + LogicalKeyboardKey.digit4, + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.digit6, +]; + +/// Who the server waits for: "Waiting for you · press Space when ready", +/// "Waiting for Alex, Mika", or '' while playing or with nothing up. +String waitingText({bool compact = false}) { + final coord = SessionState.instance.coordinator; + final r = coord.serverState.value; + if (r == null || r.media == null || r.transport != 'held') return ''; + final self = coord.selfMember.value; + if (self != null && !self.ready) { + return compact ? 'waiting for you' : 'Waiting for you · press Space when ready'; + } + final waiting = [for (final m in r.members) if (!m.ready && !m.stale) m.name]; + if (waiting.isEmpty) return compact ? '' : 'Paused'; + return '${compact ? 'waiting' : 'Waiting'} for ${waiting.join(', ')}'; +} + +/// While held: who everyone is waiting for, with their avatars. When it +/// is you, a click marks you ready. +class WaitingPill extends StatelessWidget { + const WaitingPill({super.key}); + + @override + Widget build(BuildContext context) { + return PartyWatch(builder: (context) { + final coord = SessionState.instance.coordinator; + final text = waitingText(); + final r = coord.serverState.value; + final self = coord.selfMember.value; + final meWaiting = self != null && !self.ready; + final who = meWaiting + ? [selfAvatarInfo()] + : [ + for (final m in r?.members ?? const []) + if (!m.ready && !m.stale) memberAvatarInfo(m, r, coord), + ]; + return AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: text.isEmpty ? 0 : 1, + child: IgnorePointer( + ignoring: text.isEmpty || !meWaiting, + child: GestureDetector( + key: const ValueKey('waiting-pill'), + onTap: coord.toggleReady, + child: MouseRegion( + cursor: meWaiting ? SystemMouseCursors.click : MouseCursor.defer, + child: Container( + padding: const EdgeInsets.fromLTRB(8, 6, 16, 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final m in who.take(4)) + Padding(padding: const EdgeInsets.only(right: 4), child: StatusAvatar(info: m, size: 28)), + const SizedBox(width: 6), + Flexible( + child: Text(text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), + ), + ], + ), + ), + ), + ), + ), + ); + }); + } +} + +/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks. +class _MiniSeekBar extends StatelessWidget { + final bool thin; + final ValueChanged? onSeek; + const _MiniSeekBar({this.thin = false, this.onSeek}); + + @override + Widget build(BuildContext context) { + final playback = SessionState.instance.coordinator.playback; + return SignalBuilder(builder: (context) { + final d = playback.duration.value; + final p = playback.position.value; + final v = d.inMilliseconds == 0 ? 0.0 : (p.inMilliseconds / d.inMilliseconds).clamp(0.0, 1.0); + final bar = LinearProgressIndicator( + value: v, + minHeight: thin ? 2 : 4, + backgroundColor: thin ? Colors.transparent : Colors.white24, + ); + if (thin || onSeek == null || d == Duration.zero) return bar; + void seekAt(double x, double width) => onSeek!(d * (x / width).clamp(0.0, 1.0)); + return LayoutBuilder( + builder: (context, box) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (e) => seekAt(e.localPosition.dx, box.maxWidth), + onHorizontalDragEnd: null, + onHorizontalDragUpdate: (e) => seekAt(e.localPosition.dx, box.maxWidth), + child: Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: bar), + ), + ); + }); + } +} + +/// Double-click on the picture (not on the bars) without joining the +/// gesture arena, so single clicks on buttons stay instant. +class DoubleClickListener extends StatefulWidget { + final VoidCallback onDoubleClick; + final double skipTop, skipBottom; + final Widget child; + const DoubleClickListener( + {super.key, required this.onDoubleClick, this.skipTop = 0, this.skipBottom = 0, required this.child}); + + @override + State createState() => _DoubleClickListenerState(); +} + +class _DoubleClickListenerState extends State { + DateTime? _last; + Offset? _lastAt; + + void _down(PointerDownEvent e) { + if (e.buttons != kPrimaryMouseButton) return; + final h = context.size?.height ?? 0; + final y = e.localPosition.dy; + if (y < widget.skipTop || y > h - widget.skipBottom) { + _last = null; + return; + } + final now = DateTime.now(); + final last = _last, at = _lastAt; + if (last != null && + at != null && + now.difference(last) < const Duration(milliseconds: 400) && + (e.localPosition - at).distance < 24) { + _last = null; + widget.onDoubleClick(); + return; + } + _last = now; + _lastAt = e.localPosition; + } + + @override + Widget build(BuildContext context) => + Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child); +} + +class _ReadyControlButton extends StatelessWidget { + final FocusNode focusNode; + const _ReadyControlButton({required this.focusNode}); + + @override + Widget build(BuildContext context) { + return SignalBuilder( + builder: (context) { + final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false; + return _BarTextButton( + focusNode: focusNode, + label: ready ? 'Ready ✓' : 'Not ready', + onPressed: SessionState.instance.coordinator.toggleReady, + ); + }, + ); + } +} + +class _BarTextButton extends StatelessWidget { + final FocusNode focusNode; + final String label; + final IconData? icon; + final String? tooltip; + final VoidCallback onPressed; + const _BarTextButton( + {required this.focusNode, required this.label, required this.onPressed, this.icon, this.tooltip}); + + @override + Widget build(BuildContext context) { + final style = TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + final text = Text(label, style: const TextStyle(fontSize: 12)); + final button = icon == null + ? TextButton(focusNode: focusNode, style: style, onPressed: onPressed, child: text) + : TextButton.icon( + focusNode: focusNode, style: style, onPressed: onPressed, icon: Icon(icon, size: 16), label: text); + return tooltip == null ? button : Tooltip(message: tooltip, child: button); + } +} diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart new file mode 100644 index 0000000..bd80167 --- /dev/null +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../jellyfin/accounts.dart'; +import '../jellyfin/accounts_panel.dart'; +import '../screens/home/about_page.dart'; +import '../screens/home/settings_page.dart'; +import '../update/update_controller.dart'; +import 'avatar.dart'; +import 'motion.dart'; + +/// The Settings tab's pages, in nav order. +enum SettingsSection { + profile(Icons.person_outline, 'Profile'), + player(Icons.play_circle_outline, 'Player'), + integrations(Icons.extension_outlined, 'Jellyfin & integrations'), + updates(Icons.system_update_alt, 'Updates'), + shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'), + about(Icons.info_outline, 'About'); + + final IconData icon; + final String label; + const SettingsSection(this.icon, this.label); +} + +/// App-wide settings under a left nav (chips on top when narrow). The +/// pages hold the existing settings panels; the shell owns [section] so +/// the profile card can open a page. +class SettingsTab extends StatelessWidget { + final SettingsSection section; + final ValueChanged onSection; + final VoidCallback? onEditProfile; + final UpdateController? updates; + /// Defaults to [JellyfinAccounts.instance]. + final JellyfinAccounts? jellyfin; + const SettingsTab({ + super.key, + required this.section, + required this.onSection, + this.onEditProfile, + this.updates, + this.jellyfin, + }); + + List _page(BuildContext context) { + switch (section) { + case SettingsSection.profile: + return [ + SettingsPanel( + title: 'You', + child: SignalBuilder( + builder: (context) { + final me = selfAvatarInfo(); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: StatusAvatar(info: me, size: 48, badges: false, ring: false), + title: Text(me.name == '?' ? 'No name yet' : me.name), + subtitle: const Text('Name, picture and colour: click your avatar in the top left'), + trailing: onEditProfile == null + ? null + : TextButton(onPressed: onEditProfile, child: const Text('Edit')), + ); + }, + ), + ), + ]; + case SettingsSection.player: + return const [ + SettingsPanel( + title: 'Player', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()], + ), + ), + SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()), + ]; + case SettingsSection.integrations: + return [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance), + ), + const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()), + ]; + case SettingsSection.updates: + return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))]; + case SettingsSection.shortcuts: + return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())]; + case SettingsSection.about: + return [AboutPanel(updates: updates)]; + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, box) { + final narrow = box.maxWidth < 760; + final nav = narrow + ? SizedBox( + height: 52, + child: HScroll( + builder: (c) => ListView( + controller: c, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + children: [ + for (final s in SettingsSection.values) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + key: ValueKey('settings-${s.name}'), + label: Text(s.label), + selected: section == s, + onSelected: (_) => onSection(s), + ), + ), + ], + ), + ), + ) + : SizedBox( + width: 250, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 20, 8, 20), + children: [ + for (final s in SettingsSection.values) + ListTile( + key: ValueKey('settings-${s.name}'), + selected: section == s, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + leading: Icon(s.icon, size: 20), + title: Text(s.label), + onTap: () => onSection(s), + ), + ], + ), + ); + final page = fadeThrough( + KeyedSubtree( + key: ValueKey(section), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 20, 24, 24), + children: [ + Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(section.label, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 14), + ..._page(context), + ], + ), + ), + ), + ], + ), + ), + ); + return narrow + ? Column(children: [nav, Expanded(child: page)]) + : Row(children: [nav, Expanded(child: page)]); + }, + ); + } +} diff --git a/apps/desktop/lib/src/shell/toasts.dart b/apps/desktop/lib/src/shell/toasts.dart new file mode 100644 index 0000000..c6ed907 --- /dev/null +++ b/apps/desktop/lib/src/shell/toasts.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show MemberView; + +import '../session/session_coordinator.dart' show ToastItem; +import '../state/session_state.dart'; +import 'avatar.dart'; +import 'motion.dart'; + +/// One toast on screen: who (null = the app itself), what, and whether it +/// is on its way out. +class ShellToast { + final int id; + final AvatarInfo? who; + final IconData? icon; + final String text; + final bool chat; + bool leaving = false; + ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false}); +} + +/// Splits a coordinator toast into who and what: feed toasts start with +/// the actor's name ("Bob joined", "Bob: hi"); anything else is the app's. +ShellToast shellToastFor(ToastItem t, Iterable names, AvatarInfo Function(String name) avatar) { + final sorted = names.where((n) => n.isNotEmpty).toSet().toList()..sort((a, b) => b.length - a.length); + for (final n in sorted) { + if (t.text.startsWith('$n: ')) { + return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true); + } + if (t.text.startsWith('$n ')) { + final rest = t.text.substring(n.length + 1); + final icon = rest.startsWith('joined') + ? Icons.login + : rest.startsWith('left') + ? Icons.logout + : rest.startsWith('kicked') + ? Icons.gavel + : rest.startsWith('changed') + ? Icons.swap_horiz + : Icons.info_outline; + return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); + } + } + return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline); +} + +/// The coordinator's toasts as cards with avatars, bottom-left: each +/// slides in, stays [life], then slides out and collapses its gap. +class PartyToasts extends StatefulWidget { + final Duration life; + const PartyToasts({super.key, this.life = const Duration(seconds: 5)}); + + @override + State createState() => _PartyToastsState(); +} + +class _PartyToastsState extends State { + final _shown = []; + final _timers = []; + Set _seen = {}; + late final void Function() _stop; + + @override + void initState() { + super.initState(); + final coord = SessionState.instance.coordinator; + // What was there before this mounted is old news. + _seen = {for (final t in coord.toasts.peek()) t.id}; + _stop = effect(() { + final items = coord.toasts.value; + final fresh = [for (final t in items) if (!_seen.contains(t.id)) t]; + _seen = {for (final t in items) t.id}; + if (fresh.isEmpty) return; + // Only new toasts re-run this, not the party they mention. + untracked(() { + final r = coord.serverState.value; + final names = [ + for (final m in r?.members ?? const []) m.name, + for (final f in coord.feedLog.value) f.actor, + ]; + for (final t in fresh) { + _add(shellToastFor(t, names, (n) => avatarForName(n, coord))); + } + }); + }); + } + + void _add(ShellToast t) { + // Not from inside the effect's build-time notification. + scheduleMicrotask(() { + if (!mounted) return; + setState(() => _shown.add(t)); + _timers.add(Timer(widget.life, () { + if (mounted) setState(() => t.leaving = true); + })); + _timers.add(Timer(widget.life + easeTime, () { + if (mounted) setState(() => _shown.remove(t)); + })); + }); + } + + @override + void dispose() { + _stop(); + for (final t in _timers) { + t.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList())); +} + +class ToastColumn extends StatelessWidget { + final List toasts; + const ToastColumn({super.key, required this.toasts}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final t in toasts) + TweenAnimationBuilder( + key: ValueKey(t.id), + tween: Tween(begin: 0, end: 1), + duration: easeTime, + curve: ease, + builder: (context, v, child) => Opacity( + opacity: v, + child: Transform.translate(offset: Offset(-24 * (1 - v), 0), child: child), + ), + // Leaving: slide back out, fade, and collapse the gap it leaves. + child: AnimatedAlign( + duration: easeTime, + curve: ease, + alignment: Alignment.topLeft, + heightFactor: t.leaving ? 0 : 1, + child: AnimatedSlide( + duration: easeTime, + curve: ease, + offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero, + child: AnimatedOpacity( + duration: easeTime, + curve: ease, + opacity: t.leaving ? 0 : 1, + child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)), + ), + ), + ), + ), + ], + ); + } +} + +class ToastCard extends StatelessWidget { + final ShellToast toast; + const ToastCard({super.key, required this.toast}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final who = toast.who; + return Material( + elevation: 8, + color: scheme.surfaceContainerHigh.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(14), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 16, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: who == null ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + if (who != null) + StatusAvatar(info: who, size: 38, badges: false) + else + CircleAvatar( + radius: 19, + backgroundColor: scheme.primary.withValues(alpha: 0.15), + child: Icon(toast.icon ?? Icons.info_outline, size: 18, color: scheme.primary), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (who != null) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text(who.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)), + ), + if (toast.icon != null) ...[ + const SizedBox(width: 6), + Icon(toast.icon, size: 16, color: scheme.primary), + ], + ], + ), + const SizedBox(height: 2), + ], + Text( + toast.text, + style: TextStyle( + fontSize: toast.chat ? 15 : 14, + color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/sync/avatar_cache.dart b/apps/desktop/lib/src/sync/avatar_cache.dart new file mode 100644 index 0000000..e31ed10 --- /dev/null +++ b/apps/desktop/lib/src/sync/avatar_cache.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; + +import 'sync_client.dart'; + +/// Member pictures by content hash (`state.members[].avatarHash`), fetched +/// once from `/a///` on the host the client dialled and +/// kept in memory. A hash names fixed bytes, so entries never go stale; +/// listeners are notified when one arrives. +class AvatarCache extends ChangeNotifier { + final SyncClient sync; + /// Fetches one picture; null on any failure. Injectable for tests. + final Future Function(Uri url) _fetch; + final Map _bytes = {}; + final Set _inFlight = {}; + final Map _failedAt = {}; + StreamSubscription? _sub; + bool _disposed = false; + + static const _retryAfter = Duration(seconds: 10); + /// Above this many pictures, ones nobody in the latest state uses go. + static const _keep = 32; + + AvatarCache(this.sync, {Future Function(Uri url)? fetch}) + : _fetch = fetch ?? _httpGet { + _sub = sync.states.listen((s) { + final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash}; + if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h)); + live.forEach(_want); + }); + } + + /// The picture for [hash], or null (none, or not here yet: it is fetched + /// and listeners hear when it lands). + Uint8List? bytesFor(String hash) { + if (hash.isEmpty) return null; + final hit = _bytes[hash]; + if (hit == null) _want(hash); + return hit; + } + + void _want(String hash) { + if (_bytes.containsKey(hash) || _inFlight.contains(hash)) return; + final failed = _failedAt[hash]; + if (failed != null && DateTime.now().difference(failed) < _retryAfter) return; + final ws = sync.wsUri; + final token = sync.token; + if (ws == null || token.isEmpty) return; + final url = Uri( + scheme: ws.scheme == 'wss' ? 'https' : 'http', + host: ws.host, + port: ws.port, + pathSegments: ['a', 'default', token, hash]); + _inFlight.add(hash); + unawaited(_fetch(url).then((bytes) { + _inFlight.remove(hash); + if (_disposed) return; + if (bytes == null || bytes.isEmpty) { + _failedAt[hash] = DateTime.now(); + return; + } + _failedAt.remove(hash); + _bytes[hash] = bytes; + notifyListeners(); + })); + } + + static Future _httpGet(Uri url) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final resp = await (await client.getUrl(url)).close().timeout(const Duration(seconds: 15)); + if (resp.statusCode != 200) { + await resp.drain(); + return null; + } + final builder = BytesBuilder(copy: false); + await for (final chunk in resp.timeout(const Duration(seconds: 15))) { + builder.add(chunk); + } + return builder.takeBytes(); + } catch (_) { + return null; + } finally { + client.close(force: true); + } + } + + @override + void dispose() { + _disposed = true; + unawaited(_sub?.cancel()); + super.dispose(); + } +} diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index c8df516..6e85151 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -33,6 +33,8 @@ class SyncClient { Stream get errors => _errors.stream; Stream get status => _status.stream; + /// The `/sync` URL last dialled; its host serves `/a/...` and the media. + Uri? wsUri; String token = ''; String memberId = ''; String? mediaUrl; @@ -53,6 +55,7 @@ class SyncClient { }) async { await disconnect(); clockSync.reset(); + this.wsUri = wsUri; _status.add(SyncStatus.connecting); final ch = WebSocketChannel.connect(wsUri); unawaited(ch.ready.then((_) {}, onError: (_) {})); @@ -240,6 +243,13 @@ class SyncClient { _send(ChatMessage(text: clean).toJson()); } + /// Mid-session name/picture change; null leaves it, an empty [avatar] + /// (base64 PNG) removes the picture. + void sendProfile({String? name, String? avatar}) { + if (name == null && avatar == null) return; + _send(ProfileIntent(name: name, avatar: avatar).toJson()); + } + void sendKick(String target, {String reason = ''}) { _send(KickRequest(target: target, reason: reason).toJson()); } diff --git a/apps/desktop/lib/src/widgets/avatar_pipeline.dart b/apps/desktop/lib/src/widgets/avatar_pipeline.dart index 1d23d86..db21dc9 100644 --- a/apps/desktop/lib/src/widgets/avatar_pipeline.dart +++ b/apps/desktop/lib/src/widgets/avatar_pipeline.dart @@ -5,23 +5,30 @@ import 'dart:typed_data'; import 'package:image/image.dart' as img; import 'package:sync_relay_core/core.dart'; +/// An image file shrunk to 128 px as base64 PNG ('' for an empty path). +/// Throws [FormatException] with a user-facing message. Future encodeAvatar(String path) async { final trimmed = path.trim(); if (trimmed.isEmpty) return ''; final file = File(trimmed); if (!await file.exists()) { - throw const FormatException('avatar file not found — check the path or pick again with Browse'); + throw const FormatException('avatar file not found — pick it again'); } late final List raw; try { raw = await file.readAsBytes(); } catch (_) { - throw const FormatException('could not read that avatar file — pick it again with Browse'); + throw const FormatException('could not read that avatar file — pick it again'); } if (raw.isEmpty) { throw const FormatException('that avatar file is empty — pick a PNG, JPG or WebP photo'); } - final decoded = img.decodeImage(Uint8List.fromList(raw)); + return encodeAvatarBytes(Uint8List.fromList(raw)); +} + +/// Any image's bytes shrunk to 128 px as base64 PNG. +String encodeAvatarBytes(Uint8List raw) { + final decoded = img.decodeImage(raw); if (decoded == null) { throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo'); } @@ -33,10 +40,16 @@ Future encodeAvatar(String path) async { backgroundColor: img.PixelUndefined(), interpolation: img.Interpolation.cubic, ); - final png = img.encodePng(resized); - final b64 = base64Encode(png); + final b64 = base64Encode(img.encodePng(resized)); if (b64.length > RelayConstants.maxAvatarBytes) { throw const FormatException('that photo is still too large after shrinking — pick a smaller one'); } return b64; } + +/// The profile's cropped PNG as sent to the server: as is when it fits +/// the cap, else shrunk like [encodeAvatarBytes]. +String avatarWireBase64(Uint8List png) { + final b64 = base64Encode(png); + return b64.length <= RelayConstants.maxAvatarBytes ? b64 : encodeAvatarBytes(png); +} diff --git a/apps/desktop/lib/src/widgets/member_avatar.dart b/apps/desktop/lib/src/widgets/member_avatar.dart deleted file mode 100644 index 266ed8f..0000000 --- a/apps/desktop/lib/src/widgets/member_avatar.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; - -class MemberAvatar extends StatelessWidget { - final String avatarBase64; - final String name; - final double size; - - static final Map _images = {}; - - const MemberAvatar({super.key, required this.avatarBase64, required this.name, this.size = 40}); - - static MemoryImage? _providerFor(String raw) { - if (raw.isEmpty) return null; - var hit = _images[raw]; - if (hit != null) return hit; - try { - hit = MemoryImage(base64Decode(raw)); - } catch (_) { - return null; - } - if (_images.length > 64) _images.clear(); - _images[raw] = hit; - return hit; - } - - @override - Widget build(BuildContext context) { - final image = _providerFor(avatarBase64); - final decoration = BoxDecoration(shape: BoxShape.circle); - if (image != null) { - return Container( - decoration: decoration, - child: ClipOval( - child: Image( - image: image, - width: size, - height: size, - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, __, ___) => _fallback(), - ), - ), - ); - } - return Container(decoration: decoration, child: _fallback()); - } - - Widget _fallback() => CircleAvatar( - radius: size / 2, - backgroundColor: Colors.grey.shade800, - child: Text( - name.isEmpty ? '?' : name[0].toUpperCase(), - style: TextStyle(fontSize: size * 0.4, color: Colors.white), - ), - ); -} diff --git a/apps/desktop/lib/src/widgets/shown_frame_probe.dart b/apps/desktop/lib/src/widgets/shown_frame_probe.dart index 31755a9..21d9f37 100644 --- a/apps/desktop/lib/src/widgets/shown_frame_probe.dart +++ b/apps/desktop/lib/src/widgets/shown_frame_probe.dart @@ -6,10 +6,9 @@ import 'package:flutter/widgets.dart'; import '../playback/frame_watchdog.dart'; /// Fingerprints what the compositor really shows of the video. Place -/// [anchor] in the video controls (built in the window and again in the -/// fullscreen route); [sample] captures the route around the newest -/// mounted anchor at ~200 px wide — the texture included — and hashes its -/// centre. This is the only per-presented-frame signal: mpv's own +/// [anchor] under a `RepaintBoundary` around the video (the session player +/// does); [sample] captures that boundary around the newest mounted anchor +/// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own /// counters keep ticking when the Flutter texture is stuck. class ShownFrameProbe { final _anchors = <_AnchorState>[]; diff --git a/apps/desktop/lib/src/widgets/window_chrome_bar.dart b/apps/desktop/lib/src/widgets/window_chrome_bar.dart deleted file mode 100644 index 0b71f7a..0000000 --- a/apps/desktop/lib/src/widgets/window_chrome_bar.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; - -import 'window_buttons.dart'; - -class WindowChromeBar extends StatelessWidget { - final String title; - final Widget? leading; - const WindowChromeBar({super.key, required this.title, this.leading}); - - @override - Widget build(BuildContext context) { - return DragToMoveArea( - child: Container( - height: 36, - color: Theme.of(context).scaffoldBackgroundColor, - child: Row( - children: [ - if (leading != null) leading!, - Padding( - padding: const EdgeInsets.only(left: 12), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const Spacer(), - const WindowButtons(), - ], - ), - ), - ); - } -} diff --git a/apps/desktop/lib/src/widgets/window_drag_layer.dart b/apps/desktop/lib/src/widgets/window_drag_layer.dart deleted file mode 100644 index 6e0ac1e..0000000 --- a/apps/desktop/lib/src/widgets/window_drag_layer.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:window_manager/window_manager.dart'; - -typedef ExclusionZone = Rect Function(Size); - -class WindowDragLayer extends StatefulWidget { - final bool enabled; - final List exclusions; - const WindowDragLayer({ - super.key, - this.enabled = true, - this.exclusions = const [], - }); - - @override - State createState() => _WindowDragLayerState(); -} - -class _WindowDragLayerState extends State { - int? _armedPointer; - Offset? _downPos; - bool _dragging = false; - - void _onDown(PointerDownEvent e) { - if (!widget.enabled) return; - _armedPointer = e.pointer; - _downPos = e.position; - _dragging = false; - } - - void _onMove(PointerMoveEvent e) { - if (e.pointer != _armedPointer || _dragging) return; - final down = _downPos; - if (down == null) return; - if ((e.position - down).distance < kTouchSlop) return; - final size = context.size; - if (size != null) { - for (final zone in widget.exclusions) { - if (zone(size).contains(down)) { - _armedPointer = null; - return; - } - } - } - _dragging = true; - windowManager.startDragging(); - } - - void _onEnd(PointerEvent e) { - if (e.pointer == _armedPointer) { - _armedPointer = null; - _dragging = false; - } - } - - @override - Widget build(BuildContext context) { - return Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: _onDown, - onPointerMove: _onMove, - onPointerUp: _onEnd, - onPointerCancel: _onEnd, - child: const SizedBox.expand(), - ); - } -} diff --git a/apps/desktop/test/avatar_cache_test.dart b/apps/desktop/test/avatar_cache_test.dart new file mode 100644 index 0000000..42b4273 --- /dev/null +++ b/apps/desktop/test/avatar_cache_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:watch_together/src/sync/avatar_cache.dart'; +import 'package:watch_together/src/sync/sync_client.dart'; + +Future _poll(FutureOr Function() cond, String what) async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 50)); + } + fail('timed out waiting for $what'); +} + +void main() { + test('pictures arrive by hash over /a/; profile renames and swaps them', () async { + final server = SyncServer(const AppConfig(port: 0, secret: 's', name: 'avatars')); + await server.start(); + final client = SyncClient(); + final cache = AvatarCache(client); + var notified = 0; + cache.addListener(() => notified++); + final first = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List.generate(196, (i) => i % 256)]); + final second = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List.generate(197, (i) => 255 - i % 256)]); + try { + await client.connect( + wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), + secret: 's', + name: 'alice', + avatar: base64Encode(first), + ); + final hash = avatarHashOf(first); + expect(client.lastState!.members.single.avatarHash, hash); + await _poll(() => cache.bytesFor(hash) != null, 'first picture'); + expect(cache.bytesFor(hash), first); + expect(notified, 1); + + client.sendProfile(name: 'bob', avatar: base64Encode(second)); + await _poll(() => client.lastState?.members.single.name == 'bob', 'rename'); + final next = client.lastState!.members.single.avatarHash; + expect(next, avatarHashOf(second)); + await _poll(() => cache.bytesFor(next) != null, 'second picture'); + expect(cache.bytesFor(hash), first, reason: 'earlier pictures stay cached'); + + client.sendProfile(avatar: ''); + await _poll(() => client.lastState?.members.single.avatarHash == '', 'picture removed'); + expect(cache.bytesFor(''), isNull); + } finally { + cache.dispose(); + await client.dispose(); + await server.stop(); + } + }); +} diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 092e0e7..2335e80 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -2,11 +2,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/schedule/ui/schedule_page.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/shell/app_shell.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'; @@ -107,64 +107,87 @@ void main() { addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp( theme: ThemeData.dark(), - home: Scaffold( - body: HomeShell( - updates: UpdateController(currentVersion: '0.0.0-dev'), - autoUpdateCheck: false, - healthGet: health ?? fakeHealth(const {}), - ), + home: AppShell( + updates: UpdateController(currentVersion: '0.0.0-dev'), + autoUpdateCheck: false, + windowControls: false, + healthGet: health ?? fakeHealth(const {}), ), )); await tester.pump(); } - 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'))); + Future openCard(WidgetTester tester, String key) async { + await tester.tap(find.byKey(ValueKey(key))); 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')); + } + + setUp(() { + SessionState.instance.recents.value = const []; + SessionState.instance.route.value = AppRoute.home; + }); + + testWidgets('tabs and settings pages switch the body', (tester) async { + await pumpShell(tester, const Size(1280, 900)); + expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('tab-schedule'))); + // The schedule shows a spinner while it loads: pump frames, not settle. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(find.byKey(const ValueKey('swap-panel')), findsNothing); + expect(find.byType(SchedulePage), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('tab-settings'))); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pumpAndSettle(); + expect(find.text('Name, picture and colour: click your avatar in the top left'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('settings-player'))); 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.tap(find.byKey(const ValueKey('settings-updates'))); await tester.pumpAndSettle(); - expect(find.text('Jellyfin'), findsOneWidget); - await tester.tap(find.byTooltip('About')); + expect(find.text('Check automatically on start'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('settings-shortcuts'))); + await tester.pumpAndSettle(); + expect(find.text('Toggle ready'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('settings-integrations'))); + await tester.pumpAndSettle(); + expect(find.text('Play the next episode automatically'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('settings-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.tap(find.byKey(const ValueKey('tab-library'))); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('join-row')), findsOneWidget); + expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget); expect(tester.takeException(), isNull); }); - testWidgets('Enter in the join row submits; errors show under it', (tester) async { + testWidgets('Enter in the join card submits; errors show under it', (tester) async { SessionState.instance.lastForm.name = ''; await pumpShell(tester, const Size(1280, 720)); + await openCard(tester, 'strip-join'); await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example'); await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - await tester.pump(); + await tester.pumpAndSettle(); 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(); + await tester.tap(find.widgetWithText(FilledButton, 'Join').last); + await tester.pumpAndSettle(); + expect(find.textContaining('display name', findRichText: true), findsOneWidget); + // A click outside closes the card; the error is still there on reopen. + await tester.tapAt(const Offset(20, 700)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('join-address')), findsNothing); + await openCard(tester, 'strip-join'); expect(find.textContaining('display name', findRichText: true), findsOneWidget); }); - testWidgets('recent tiles show live status from /health', (tester) async { + testWidgets('recent rows show live status from /health', (tester) async { SessionState.instance.recents.value = [ join('alpha.example', '8080'), join('beta.example', '9000', 'pw'), @@ -181,54 +204,34 @@ void main() { }, 'gamma.example': {'ok': true, 'name': '', 'proto': -1, 'viewers': 0}, })); - await tester.pump(); - Finder inTile(String key, Finder f) => + await openCard(tester, 'strip-join'); + Finder inRow(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(inRow('alpha.example:8080', find.text('Movie night')), findsOneWidget); + expect(inRow('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget); + expect(inRow('alpha.example:8080', find.text('needs update')), findsNothing); + expect(inRow('beta.example:9000', find.text('○ offline')), findsOneWidget); + expect(inRow('beta.example:9000', find.text('beta.example')), findsOneWidget); + expect(inRow('gamma.example:7000', find.text('needs update')), findsOneWidget); + // Hosted entries live on the Host card, not in the join list. 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.tap(inRow('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 { + testWidgets('no recents: the join card is just the form', (tester) async { await pumpShell(tester, const Size(1280, 720)); - expect(find.byKey(const ValueKey('recents-empty')), findsOneWidget); + await openCard(tester, 'strip-join'); + expect(find.text('Recent'), findsNothing); + expect(find.byKey(const ValueKey('join-row')), 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 { + testWidgets('library: picker segments switch the input; Integrations opens Settings', (tester) async { await pumpShell(tester, const Size(1280, 900)); - await tester.tap(find.byKey(const ValueKey('tab-host'))); + await tester.tap(find.text('File')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('media-file')), findsOneWidget); expect(find.text('Browse'), findsOneWidget); @@ -236,6 +239,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('media-link')), findsOneWidget); expect(find.text('Paste'), findsOneWidget); + // Offline nothing goes up. + expect(find.text('Join or host to watch together'), findsOneWidget); await tester.tap(find.text('Jellyfin')); await tester.pumpAndSettle(); // No linked server: sign-in right in the picker. @@ -246,44 +251,29 @@ void main() { 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 { + testWidgets('host without a name errors in the host card', (tester) async { SessionState.instance.lastForm.name = ''; + SessionState.instance.recents.value = [const RecentServer(hosted: true, host: '', port: '8181', secret: 's')]; await pumpShell(tester, const Size(1280, 900)); - await tester.tap(find.byKey(const ValueKey('tab-host'))); + await openCard(tester, 'strip-host'); + expect(find.text('No password — anyone can join'), findsOneWidget); + expect(find.text('You pick what to watch afterwards, in the library.'), findsOneWidget); + // A hosted port refills the fields. + await tester.tap(find.byKey(const ValueKey('recent-host:8181'))); + await tester.pump(); + expect(find.text('No password — anyone can join'), findsNothing); + await tester.tap(find.widgetWithText(FilledButton, 'Host').last); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Host')); - await tester.pump(); - await tester.pump(); - expect( - find.descendant( - of: find.byKey(const ValueKey('host-column')), - matching: find.textContaining('display name', findRichText: true), - ), - findsOneWidget, - ); + expect(find.textContaining('display name', findRichText: true), findsOneWidget); }); - testWidgets('narrow window: bar, join row and host tab fit', (tester) async { + testWidgets('narrow window: strip, cards and library fit', (tester) async { await pumpShell(tester, const Size(480, 800)); + expect(tester.takeException(), isNull); + await openCard(tester, 'strip-join'); expect(find.byKey(const ValueKey('join-row')), findsOneWidget); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('tab-host'))); + await tester.tapAt(const Offset(5, 790)); await tester.pumpAndSettle(); await tester.tap(find.text('Jellyfin')); await tester.pumpAndSettle(); diff --git a/apps/desktop/test/jellyfin_accounts_test.dart b/apps/desktop/test/jellyfin_accounts_test.dart index 280c5e5..688bee6 100644 --- a/apps/desktop/test/jellyfin_accounts_test.dart +++ b/apps/desktop/test/jellyfin_accounts_test.dart @@ -7,7 +7,6 @@ import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/format.dart'; import 'package:watch_together/src/jellyfin/sign_in.dart'; -import 'package:watch_together/src/screens/home/host_tab.dart' show mediaSummary; import 'package:watch_together/src/screens/home/media_picker.dart'; /// Captured Jellyfin 10.11 answers shared with packages/core's client tests. @@ -327,9 +326,8 @@ void main() { expect(back, m); expect(back.runtime, const Duration(minutes: 23)); expect(back.subtitle, 'Black Summoner · S1E6'); - expect(mediaSummary(m), 'Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'); - expect(mediaSummary(const FileMedia('/a/b/movie.mkv')), 'Playing: movie.mkv'); - expect(mediaSummary(null), 'No media selected (optional).'); + expect(m.label, 'Rice and Thieves and Heroes — Black Summoner · S1E6'); + expect(const FileMedia('/a/b/movie.mkv').label, 'movie.mkv'); expect(JellyfinMedia.fromJson({'serverId': 1}), isNull); }); diff --git a/apps/desktop/test/jellyfin_browser_test.dart b/apps/desktop/test/jellyfin_browser_test.dart index 2845226..08b0216 100644 --- a/apps/desktop/test/jellyfin_browser_test.dart +++ b/apps/desktop/test/jellyfin_browser_test.dart @@ -5,10 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts_panel.dart'; import 'package:watch_together/src/jellyfin/browser.dart'; -import 'package:watch_together/src/screens/home/connect_flow.dart'; -import 'package:watch_together/src/screens/home/host_tab.dart'; import 'package:watch_together/src/screens/home/media_picker.dart'; -import 'package:watch_together/src/state/session_state.dart'; +import 'package:watch_together/src/shell/library_tab.dart'; import 'jellyfin_accounts_test.dart' show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id; @@ -270,7 +268,7 @@ void main() { expect(find.byType(JellyfinBrowser), findsOneWidget); }); - testWidgets('Host tab: browser beside the server column, enlarged with a host bar, stacked when narrow', + testWidgets('Library tab: the last pick comes back; offline it only browses', (tester) async { await setSize(tester, const Size(1800, 1000)); final (accounts, _) = await linkedAccounts(); @@ -281,32 +279,41 @@ void main() { title: 'Rice and Thieves and Heroes', subtitle: 'Black Summoner · S1E6'); await accounts.rememberPick(pick); - final fields = HostFields(ConnectForm(), jellyfin: accounts); - final flow = ConnectFlow(); - addTearDown(() { - fields.dispose(); - flow.dispose(); - }); - await tester.pumpWidget(MaterialApp( - theme: ThemeData.dark(), home: Scaffold(body: HostTab(flow: flow, fields: fields)))); + final c = MediaPickerController(); + addTearDown(c.dispose); + await restoreLastPick(c, accounts, () => true); + final put = []; + Future pump({required bool online}) => tester.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: LibraryTab( + controller: c, + accounts: accounts, + online: online, + isHost: false, + onPutUp: (m) async { + put.add(m); + return null; + }, + ), + ))); + await pump(online: false); await settle(tester); // The saved pick comes back on the Jellyfin source. - expect(fields.media.selection, pick); + expect(c.selection, pick); expect(find.byType(JellyfinBrowser), findsOneWidget); - expect(find.byKey(const ValueKey('host-column')), findsOneWidget); - expect(find.text('Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget); + expect(find.text('Join or host to watch together'), findsOneWidget); + final go = find.byKey(const ValueKey('swap-go')); + expect(tester.widget(go).onPressed, isNull); - await tester.tap(find.byKey(const ValueKey('jf-expand'))); - await settle(tester, 2); - expect(find.byKey(const ValueKey('host-column')), findsNothing); - expect(find.byKey(const ValueKey('host-bar')), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.byKey(const ValueKey('jf-expand'))); - await settle(tester, 2); + await pump(online: true); + await settle(tester); + expect(find.text('Put up: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget); + await tester.tap(go); + await settle(tester); + expect(put, [pick]); tester.view.physicalSize = const Size(800, 900); await settle(tester, 2); - expect(find.byKey(const ValueKey('host-picker')), findsOneWidget); expect(find.byType(JellyfinBrowser), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index 964307b..8a5ed67 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -9,12 +9,12 @@ import 'package:watch_together/src/jellyfin/browse_scope.dart'; import 'package:watch_together/src/jellyfin/progress.dart'; import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; -import 'package:watch_together/src/screens/home/host_tab.dart' show hostJellyfinMedia; import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/screens/session/jellyfin_widgets.dart'; import 'package:watch_together/src/screens/session/swap_panel.dart'; -import 'package:watch_together/src/screens/session_screen.dart' show viaHostOf; import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance; +import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia; +import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf; import 'jellyfin_accounts_test.dart'; import 'jellyfin_browser_test.dart' show app, setSize, settle; @@ -125,16 +125,16 @@ void main() { title: 'Rice and Thieves and Heroes', subtitle: 'Black Summoner · S1E6', runtime: Duration(minutes: 23)); - final media = hostJellyfinMedia(accounts, pick); + final media = putUpJellyfinMedia(accounts, pick); expect(media.url, isNull); expect(media.jellyfin!.itemId, episodeId); expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6'); expect(media.jellyfin!.runtimeSecs, 23 * 60); expect(jsonEncode(SwapIntent(media: media).toJson()), isNot(contains('TEST_ACCESS_TOKEN'))); - expect(() => hostJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')), + expect(() => putUpJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')), throwsFormatException); accounts.markExpired(fixtureServerId); - expect(() => hostJellyfinMedia(accounts, pick), throwsFormatException); + expect(() => putUpJellyfinMedia(accounts, pick), throwsFormatException); }); test('a pasted Jellyfin link becomes a ref (ServerId from /System/Info/Public, no api_key)', @@ -532,7 +532,7 @@ void main() { swapped.add(m); return null; }, - onClose: () => closed++, + onDone: () => closed++, ))); await settle(tester); // A guest's file goes through the server's relay. @@ -553,7 +553,7 @@ void main() { expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget); await tester.pumpWidget(app(SwapPanel( - controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {}))); + controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x'))); await settle(tester); expect(find.text('File'), findsOneWidget); await tester.tap(find.text('File')); @@ -580,7 +580,6 @@ void main() { isHost: false, jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep), onSwap: (_) async => null, - onClose: () {}, ))); await settle(tester, 10); expect(tester.widget(find.byKey(const ValueKey('season-$season2Id'))).selected, diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart new file mode 100644 index 0000000..1423aa3 --- /dev/null +++ b/apps/desktop/test/shell_test.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/screens/home/profile.dart'; +import 'package:watch_together/src/shell/app_shell.dart'; +import 'package:watch_together/src/shell/avatar.dart'; +import 'package:watch_together/src/shell/motion.dart'; +import 'package:watch_together/src/shell/party_strip.dart'; +import 'package:watch_together/src/shell/profile_card.dart'; +import 'package:watch_together/src/shell/session_player.dart'; +import 'package:watch_together/src/shell/settings_tab.dart' show SettingsTab; +import 'package:watch_together/src/shell/toasts.dart'; +import 'package:watch_together/src/session/session_coordinator.dart' show ToastItem; +import 'package:watch_together/src/state/session_state.dart'; +import 'package:watch_together/src/update/update_controller.dart'; + +/// Stands in for media_kit's `Video` (which hangs flutter_test) and +/// counts how often one is created. +class FakeVideo extends StatefulWidget { + static int created = 0; + const FakeVideo({super.key}); + + @override + State createState() => _FakeVideoState(); +} + +class _FakeVideoState extends State { + @override + void initState() { + super.initState(); + FakeVideo.created++; + } + + @override + Widget build(BuildContext context) => const ColoredBox(color: Colors.black); +} + +MemberView member(String id, String name, {bool ready = false, bool stale = false}) => + MemberView(id: id, name: name, ready: ready, inSet: ready, buffered: 30, stale: stale); + +void main() { + final session = SessionState.instance; + final coord = session.coordinator; + + void online(List members, {bool media = true}) { + coord.serverState.value = StateBroadcast( + eventId: 1, + transport: 'held', + position: 0, + serverTime: DateTime.now().toUtc(), + mediaId: media ? 1 : 0, + media: media ? {'name': 'Movie.mkv', 'size': 1, 'duration': 0} : null, + members: members, + ); + session.route.value = AppRoute.session; + } + + setUp(() { + FakeVideo.created = 0; + session.recents.value = const []; + }); + + tearDown(() { + session.route.value = AppRoute.home; + coord.serverState.value = null; + coord.playback.alive.value = false; + coord.toasts.value = []; + }); + + Future pumpShell(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: AppShell( + updates: UpdateController(currentVersion: '0.0.0-dev'), + autoUpdateCheck: false, + windowControls: false, + videoBuilder: (context, controls) => const FakeVideo(), + ), + )); + await tester.pumpAndSettle(); + } + + final party = [ + member('a', 'Alex', ready: true), + member('b', 'Bob'), + member('c', 'Cleo', ready: true), + member('d', 'Dan'), + member('e', 'Eve', stale: true), + member('f', 'Finn'), + ]; + + group('party strip', () { + testWidgets('offline: Join and Host, no party', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + expect(find.byKey(const ValueKey('strip-join')), findsOneWidget); + expect(find.byKey(const ValueKey('strip-host')), findsOneWidget); + expect(find.text('Offline'), findsOneWidget); + expect(find.byKey(const ValueKey('party-avatars')), findsNothing); + expect(find.byKey(const ValueKey('strip-leave')), findsNothing); + expect(tester.getSize(find.byType(PartyStrip)).height, partyStripHeight); + }); + + testWidgets('online: avatars, server and readiness, chat, Leave', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + online(party, media: false); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('strip-join')), findsNothing); + expect(find.byKey(const ValueKey('strip-leave')), findsOneWidget); + expect(find.byKey(const ValueKey('strip-chat')), findsOneWidget); + expect(find.text('ready 2/6'), findsOneWidget); + final stack = find.byKey(const ValueKey('party-avatars')); + // Five shown, the sixth as "+1"; ready ones first. + expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(5)); + expect(find.descendant(of: stack, matching: find.text('+1')), findsOneWidget); + final alex = tester.getTopLeft(find.descendant(of: stack, matching: find.byTooltip('Alex'))); + final bob = tester.getTopLeft(find.descendant(of: stack, matching: find.byTooltip('Bob'))); + expect(alex.dx, lessThan(bob.dx)); + // Leave asks first. + await tester.tap(find.byKey(const ValueKey('strip-leave'))); + await tester.pumpAndSettle(); + expect(find.text('Leave server?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(session.route.value, AppRoute.session); + }); + + testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async { + await pumpShell(tester, const Size(1000, 800)); + online(party, media: false); + await tester.pumpAndSettle(); + final stack = find.byKey(const ValueKey('party-avatars')); + expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(3)); + expect(find.descendant(of: stack, matching: find.text('+3')), findsOneWidget); + expect(find.byKey(const ValueKey('server-label')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('tabs: the underline moves to the selected one', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + double underline(String tab) => tester + .getSize(find.descendant(of: find.byKey(ValueKey('tab-$tab')), matching: find.byType(AnimatedContainer))) + .width; + expect(underline('library'), 22); + expect(underline('settings'), 0); + await tester.tap(find.byKey(const ValueKey('tab-settings'))); + await tester.pumpAndSettle(); + expect(underline('library'), 0); + expect(underline('settings'), 22); + }); + }); + + group('popovers', () { + testWidgets('members card opens from the avatars and closes outside', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + online(party, media: false); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('avatar-stack'))); + await tester.pumpAndSettle(); + final card = find.byKey(const ValueKey('members-card')); + expect(card, findsOneWidget); + for (final m in party) { + expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget); + } + expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget); + // The kick menu asks before kicking. + await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Kick')); + await tester.pumpAndSettle(); + expect(find.text('Remove Bob?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(40, 700)); + await tester.pumpAndSettle(); + expect(card, findsNothing); + }); + + testWidgets('profile card opens from your avatar; its links open Settings', (tester) async { + await pumpShell(tester, const Size(1280, 800)); + await tester.tap(find.byKey(const ValueKey('strip-profile'))); + await tester.pumpAndSettle(); + expect(find.byType(ProfileCard), findsOneWidget); + expect(find.text('offline'), findsOneWidget); + await tester.tap(find.text('About')); + await tester.pumpAndSettle(); + expect(find.byType(ProfileCard), findsNothing); + expect(find.text('Dev build — update checks are off'), findsOneWidget); + }); + + testWidgets('showPopover fades and scales in, Esc closes it', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showPopover(context, at: Alignment.topRight, child: const Text('card')), + child: const Text('open'), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(easeTime ~/ 2); + final fade = tester.widget( + find.ancestor(of: find.text('card'), matching: find.byType(FadeTransition)).first); + expect(fade.opacity.value, inExclusiveRange(0.0, 1.0)); + await tester.pumpAndSettle(); + expect(find.text('card'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('card'), findsNothing); + }); + }); + + testWidgets('profile rename goes to the server through the hook', (tester) async { + final pushed = []; + final p = Profile.instance; + final before = p.push; + p.push = ({String? name, String? avatar}) => pushed.add(name); + addTearDown(() => p.push = before); + session.lastForm.name = 'Old'; + p.name.value = 'Old'; + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: Center(child: ProfileCard(onOpenSettings: (_) {}))), + )); + await tester.enterText(find.byKey(const ValueKey('profile-name')), 'Neo'); + await tester.pump(); + expect(p.name.value, 'Neo'); + expect(session.lastForm.name, 'Neo'); + // Typing settles before it is sent, once. + expect(pushed, isEmpty); + await tester.pump(const Duration(milliseconds: 700)); + expect(pushed, ['Neo']); + }); + + testWidgets('player: mini and full share one video, never two', (tester) async { + coord.playback.alive.value = true; + await pumpShell(tester, const Size(1280, 800)); + online(party); + await tester.pumpAndSettle(); + final videos = find.byType(FakeVideo); + AppShellState shell() => tester.state(find.byType(AppShell)); + // Joined with something up: it opens full. + expect(shell().mode, PlayerMode.full); + expect(videos, findsOneWidget); + expect(tester.getSize(videos), tester.getSize(find.byKey(const ValueKey('body')))); + + Future stepThrough() async { + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(videos, findsOneWidget); + } + await tester.pumpAndSettle(); + expect(videos, findsOneWidget); + } + + // A tab sends it to the mini player. + await tester.tap(find.byKey(const ValueKey('tab-settings'))); + await stepThrough(); + expect(shell().mode, PlayerMode.mini); + expect(tester.getSize(videos), AppShellState.miniSize); + expect(find.byType(SettingsTab), findsOneWidget); + // Double-click on the mini: full again. + final mini = tester.getCenter(videos); + await tester.tapAt(mini); + await tester.tapAt(mini); + await stepThrough(); + expect(shell().mode, PlayerMode.full); + // F: fullscreen hides the strip; Esc leaves it. + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await stepThrough(); + expect(shell().fullscreen, isTrue); + expect(find.byType(PartyStrip), findsNothing); + // Player keys are debounced on the wall clock. + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 400))); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await stepThrough(); + expect(shell().fullscreen, isFalse); + expect(find.byType(PartyStrip), findsOneWidget); + // Never rebuilt from scratch on the way. + expect(FakeVideo.created, 1); + // Offline: no player at all. + session.route.value = AppRoute.home; + await tester.pumpAndSettle(); + expect(videos, findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('waiting pill: who is not ready, or you', (tester) async { + coord.playback.alive.value = true; + await pumpShell(tester, const Size(1280, 800)); + online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]); + await tester.pumpAndSettle(); + expect(find.text('Waiting for Bob, Cleo'), findsOneWidget); + }); + + test('toasts: feed toasts carry the actor, the rest are the app\'s', () { + AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal); + final at = DateTime.now(); + final names = ['Bob', 'Bob Jr']; + final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at), names, who); + expect(chat.who!.name, 'Bob Jr'); + expect(chat.text, 'hi there'); + expect(chat.chat, isTrue); + final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at), names, who); + expect(joined.who!.name, 'Bob'); + expect(joined.text, 'joined'); + expect(joined.icon, Icons.login); + final app = shellToastFor(ToastItem(id: 3, text: 'Video froze — reloaded', at: at), names, who); + expect(app.who, isNull); + }); + + testWidgets('toasts slide in with an avatar and leave after their time', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1))))); + coord.toasts.value = [ToastItem(id: 1, text: 'Video froze — reloaded', at: DateTime.now())]; + await tester.pump(); + await tester.pump(); + expect(find.text('Video froze — reloaded'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text('Video froze — reloaded'), findsNothing); + }); + + testWidgets('status avatar fills its box at any size', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Center( + child: StatusAvatar(info: AvatarInfo(name: 'Zed', color: Colors.teal, ready: true), size: 88), + ), + )); + final circle = tester.getSize(find.byType(CircleAvatar)); + // Ring and gap (2 x 4 px each side at this size) inside the 88 px box. + expect(circle.width, 88 - 4 * 4); + expect(find.text('Z'), findsOneWidget); + }); +} diff --git a/apps/desktop/test/uosc_controls_test.dart b/apps/desktop/test/uosc_controls_test.dart index 13a3463..7a40562 100644 --- a/apps/desktop/test/uosc_controls_test.dart +++ b/apps/desktop/test/uosc_controls_test.dart @@ -11,9 +11,9 @@ import 'package:watch_together/src/controls/uosc/timeline.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/playback/media_marks.dart'; import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode; -import 'package:watch_together/src/screens/home/settings_page.dart'; import 'package:watch_together/src/screens/session/player_controls.dart' show TrackPick; import 'package:watch_together/src/shaders/shader_presets.dart'; +import 'package:watch_together/src/shell/settings_tab.dart'; import 'package:watch_together/src/state/settings.dart'; import 'package:watch_together/src/update/update_controller.dart'; @@ -456,7 +456,13 @@ void main() { addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp( theme: ThemeData.dark(), - home: Scaffold(body: SettingsPage(updates: UpdateController(currentVersion: '0.0.0-dev'))), + home: Scaffold( + body: SettingsTab( + section: SettingsSection.player, + onSection: (_) {}, + updates: UpdateController(currentVersion: '0.0.0-dev'), + ), + ), )); final toggle = find.byKey(const ValueKey('mpv-controls')); expect(find.text('Experimental: mpv-style controls'), findsOneWidget); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e3e9eb4..90665c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,8 +36,8 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a `127.0.0.1` like any other client. Server logs go to the app's log with a `[server]` prefix. The isolate runs with `errorsAreFatal: false`: an uncaught async error is logged and the server keeps going. If the isolate - dies anyway, the session shows "server crashed: ..." and leaves; Home - shows it on the Host card (during setup the host attempt fails with it). + dies anyway, the session shows "server crashed: ..." and leaves; the + shell says so in a dialog (during setup the host attempt fails with it). Stop (leave, close, cancel during setup, a failed own join, being kicked) sends `stop`, waits up to 3 s for exit, then kills the isolate. - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` @@ -500,37 +500,95 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, matched with `matchTrack` against the source's streams (mapped to mpv ids with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes on the wire. -- **Swap panel**: "Change video" in the side panel opens the shared - `MediaPicker` over the video (`screens/session/swap_panel.dart`); Link +- **Change video**: the player's "Change video" opens the Library tab + (the shared `MediaPicker`, `screens/session/swap_panel.dart`); Link goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only) through `ServerIsolate.setFile` and a matching `swap`. ## Desktop UI -One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`): -profile chip, Join and Host tabs, the update indicator, Settings / -Integrations / About buttons, and the window drag area and buttons. Only the -selected view is mounted; form state lives in `HomeShell`. Join -(`join_tab.dart`): one compact address/port/password row, then up to 6 -Recent server tiles in a grid that adds columns as the window widens. While -the Join tab is visible each tile polls the server's `GET /health` every -10 s (`health_probe.dart`) for online, name, viewer count and a "needs -update" badge when `proto` differs from ours. Host (`host_tab.dart`): the -media picker (`media_picker.dart`: Jellyfin, Link, File; a reusable widget -driven by a `MediaPickerController` whose `selection` is a sealed -`PickedMedia`: `LinkMedia`, `FileMedia` or `JellyfinMedia`, see -`picked_media.dart`) fills the main area; port, password and Host sit in a -340 px column on the right, below the picker under 860 px. With the -Jellyfin source the browser fills the height and can be enlarged, which -turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so -they stay compact on ultrawide screens. Submit state (busy, cancel, -per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware -decoding, experimental mpv-style controls, Jellyfin auto-advance, updates, -video shaders, shortcuts), Integrations -(linked Jellyfin servers) and About open in the same body. Settings persist -in two Hive boxes: `connect_form` (last form and recents; saved even when a -join fails) and `settings`. Link media names are derived from the URL path -and never contain a query string. +One window, `AppShell` (`lib/src/shell/`), whatever the connection state +(online = `SessionState.route` is `session`). Motion is shared +(`motion.dart`): one curve (`fastEaseInToSlowEaseOut`, 320 ms), a fade +through for views that replace each other (the old one gone after 35%, the +new one fading and scaling in from 0.985), `showPopover` (cards that fade +and scale in from where they open; they scroll when the window is short) +and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them +sideways until an end, then the page). + +- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card), + the Library / Schedule / Settings tabs with a growing underline (an + `HScroll` when narrow), the update indicator, then offline Join and Host + (connect cards), online the party: `AvatarStack` (host first, then + ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and + "ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave + (asks first). The stack opens the members card (status, host / relay + pills, "via host", force ready, kick). Empty strip space is the window + drag area; the window buttons sit at its end. Debug builds add a button + that opens the layout mock (`screens/demo/`). +- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the + picture with a gap in the background colour (both scale with the size): + green ready, amber buffering (with a spinner), grey not ready, dimmed + stale; a crown for the host (only your own app knows it hosts: the + state has no host flag) and a tower for the member passing the video + on. `memberAvatarImage` is the one picture lookup: yours from the + profile, anyone else's from `coordinator.avatars` by `avatarHash`. + Colours are local: yours is picked in the profile, others' come from + their name. +- **Connect cards** (`connect_cards.dart`): Join lists recent servers as + one-click rows that poll `GET /health` every 10 s while the card is open + (`health_probe.dart`: online, name, viewer count, "needs update" when + `proto` differs from ours), then address, port and password. Host takes + a port and a password (hosted ports as chips) and puts nothing up: what + to watch is picked in the library afterwards. Submit state (busy, + cancel, per-card errors) is `ConnectFlow`, owned by the shell so an + attempt and its error outlive the card; a card closes itself once + online. +- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card + tinted with your colour: picture (hover "Change" opens `CropPicker`: + drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px + PNG), name edited in place, colour swatches, links to the Settings pages. + `Profile` stores the name in the connect form and the PNG and colour in + the `connect_form` box (an old picture path is shrunk once and kept as + bytes). Online, a name change (once typing settles) or picture change + goes out through `Profile.push` (`coordinator.updateProfile`). +- **Tabs**: Library (`library_tab.dart`) is the media picker + (`media_picker.dart`: Jellyfin, Link, File; a `MediaPickerController` + whose `selection` is a sealed `PickedMedia`, see `picked_media.dart`) + with "Put it up for everyone" (`screens/session/swap_panel.dart`); it + starts on what was last put up. Offline it only browses. Schedule is a + placeholder. Settings (`settings_tab.dart`) has a left nav (chips under + 760 px) over the settings panels (`screens/home/settings_page.dart`): + profile, player (hardware decoding, mpv-style controls, shaders), + Jellyfin & integrations (linked servers, auto-advance), updates, + shortcuts, about. +- **Player** (`session_player.dart`): `SessionPlayer` holds the one + `Video` of the current player (a new `GlobalKey` per player generation). + The shell keeps it in one layer of the body at one place in the element + tree: full it fills the body, mini it is 384x216 in a corner (drag it, it + snaps to the nearest corner with the shared curve). A mode change fades + the layer out, moves it while invisible and fades it in (with the tabs + when the full player comes or goes), so the video is never mounted twice + nor remounted. A tab sends the full player to mini; × on the mini closes + it (muted, still in the server) and the strip brings it back. Fullscreen + is `windowManager.setFullScreen` with the strip hidden (party avatars in + a corner while paused); F, Esc and double-click on the picture toggle it. + The player keys (Settings → Keyboard shortcuts) work while the player is + full and nothing is in front of it. Something coming up while the player + is closed opens it: full right after joining or after putting it up + here, else mini. While held, a pill under the picture names who + everyone waits for, or says "Waiting for you · press Space" (a click + marks you ready); the mini shows it in its hover bar. +- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right + (strip button or Tab): chat with avatars, other activity as quiet lines. + **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with + the actor's avatar when the text starts with a member's name; each + slides in, stays 5 s, then slides out and collapses its gap. + +Settings persist in two Hive boxes: `connect_form` (last form, recents and +the profile picture and colour; saved even when a join fails) and +`settings`. Link media names are derived from the URL path and never +contain a query string. Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): - `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one @@ -555,34 +613,37 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): key changes only when the image does. An inspector pane follows hover/focus from 1400 px browser width. - Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId, - mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab - remembers the last pick in the `jellyfin` box and hosts it as a ref - (`hostJellyfinMedia`, `ConnectFlow.submit(media:)`); the hosted server - gets the relay credential (`relayCredentialFor`: the account of the + mediaSourceId?, title, subtitle, runtime; no credentials). The library + remembers the last pick in the `jellyfin` box and puts it up as a ref + (`putUpJellyfinMedia`, `swapPicked`); a hosted server gets the relay + credential (`relayCredentialFor`: the account of the item's server, else the default one, not expired; its token with exactly `JellyfinAccounts.clientInfo`'s device fields). - Browser location: every navigation saves the route stack per server (`browse:` in the `jellyfin` box, `encodeBrowseStack`: ids, - names and the fields pages start from), so the Host tab, the swap panel - and the next start open where the browser was. A `BrowseStart` overrides - it: the swap panel opens on the series of the episode playing, its + names and the fields pages start from), so the library and the next + start open where the browser was. A `BrowseStart` overrides it: "Change + video" opens the library on the series of the episode playing, its season selected and the episode highlighted. Session controls: media_kit's `MaterialDesktopVideoControls` with our -bars (`screens/session_screen.dart`, pieces in -`screens/session/player_controls.dart`). Its seek bar is off; ours +bars (`shell/session_player.dart`, pieces in +`screens/session/player_controls.dart`); its own double-click and keys are +off (its double-click pushes a fullscreen route with a second `Video`), the +picture's double-click is a raw pointer observer (`DoubleClickListener`). +The top bar holds the title, Change video, chat and back-to-library. Its seek bar is off; ours (`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) sits in the bottom bar, which is taller by the bar's height (the top bar keeps its row at the top). A drag sends no hover events, so the bar keeps the controls up through a guarded dynamic `onHover()` on media_kit's private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the auto-hiding controls, so -they show in fullscreen (whose route renders only the video and controls). +they stay up when the controls hide. Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme; Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s controls builder wraps both sets in `SessionControlsSwitch`, which follows -the setting live, in the window and in fullscreen. `UoscControls` is our +the setting live. `UoscControls` is our own frame (no media_kit controls state): mouse movement shows it, 3 s without any hides it and the cursor; `hold` keeps it up during a timeline drag and while a menu route is open, double-click toggles fullscreen. @@ -591,19 +652,20 @@ drag and while a menu route is open, double-click toggles fullscreen. diamonds on the top edge, current time and duration in boxes inside it, a hover tip with the chapter title over the hover time; hidden, it shrinks to a 3 px line with chapter ticks. Above it a row of flat buttons: menu (≡: -change video, people, reload, mark everyone ready, fullscreen, leave), +change video, chat & activity, reload, mark everyone ready, fullscreen, +leave), subtitles and audio with track counts, a shader badge ("A HQ", opening the shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, -chapters, previous / next episode, change video, people, volume, -fullscreen. Menus (`showUoscMenu`: a popup route above the button, the +chapters, previous / next episode, change video, volume, fullscreen; chat +and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the current row highlighted) list tracks and chapters with start times. All actions are the coordinator's (seeks through `player.seek`, the user path; `selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are the same in both sets. -Window chrome is frameless (`window_manager`): every screen brings its own -drag area and min/max/close buttons (Home's top bar; the session uses -`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`. +Window chrome is frameless (`window_manager`): the party strip is the drag +area and holds the min/max/close buttons (`widgets/window_buttons.dart`); +fullscreen has neither. ## Health reporting @@ -614,7 +676,7 @@ host offline, and starvation ("waiting for X - N.N s buffered"). Errors are logged with a stack trace at the boundary, then turned into a displayable state. The server's `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so -it carries nothing sensitive. Home's recent-server tiles poll it for live +it carries nothing sensitive. The Join card's recent servers poll it for live status. ## Updater @@ -634,7 +696,7 @@ running exe on NTFS. The manifest's `assets` maps `linux-x64` / `windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from -the CLI; `UpdateController` runs the same code for Home's update indicator, +the CLI; `UpdateController` runs the same code for the strip's update indicator, which opens the update card, and restarts the app ("Update and restart"). Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in this repo, written by `tool/release.sh`. @@ -667,5 +729,7 @@ this repo, written by `tool/release.sh`. their machine. Prior art (Plex Relay, Tailscale DERP) relays as a bandwidth-capped fallback on someone else's servers; here the server is the owner's, so it carries full quality. -- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar): - simple, costs host uplink; hash-based serving is on the roadmap. +- Avatars by content hash: `state` carries only `avatarHash`; the server keeps + the bytes of current members in memory and serves them at `/a/...`, which + clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays + small whatever the picture size (cap 256 KiB base64 per avatar). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 19a99fa..b242ab9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -64,9 +64,10 @@ and `e2e_check` (simulated viewers against a live server). - Desktop tests that write files use `WT_TEST_TMP` (default: system temp). Traps: -- Never pump the session screen or media_kit `Video` in `flutter test`: the - runner hangs (kill the `flutter_tester` by PID). Test pure logic - (`StateDiff`, coordinator without widgets) instead. +- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill + the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in, + so shell tests run the real player layout without it; otherwise test pure + logic (`StateDiff`, coordinator without widgets). - Never depend on a server-side WebSocket `onDone`; assert presence with deadline polling. - `sub.asFuture()` can resolve before `onDone`; poll with a deadline. @@ -125,14 +126,19 @@ Host. Without the app: `dart run packages/server/bin/server.dart`. - Overlays that are hidden but mounted keep focus (Space falls into a hidden button). Use `ExcludeFocus` on closed subtrees and unfocus on close; reuse the shared no-focus node for overlay buttons. -- `modifyVolumeOnScroll: false` on the session controls is load-bearing for - window drag (volume is the speaker button only). A `GestureDetector` below - media_kit's overlay loses the gesture arena; the drag layer is a raw pointer - observer on top. -- New screens must bring their own drag area and window buttons (frameless - window). +- `toggleFullscreenOnDoublePress: false` on the session controls is + load-bearing: media_kit's double-click pushes its own fullscreen route with + a second `Video`. A `GestureDetector` below media_kit's overlay never sees + the click (its `MouseRegion` takes the hit) and one around it would delay + every button click (double-tap holds the arena), so the double-click is a + raw pointer observer (`DoubleClickListener`). +- The window is frameless: the party strip is the only drag area and holds + the window buttons. +- Signal-backed singletons (`Profile`, `SessionState`) live across tests; + never cache a `Future` in them (each test runs in its own zone, and a + Future from an earlier test never completes in the next). - Chat arrives twice from the server (see PROTOCOL.md); the client drops the textless feed copy. -- Tab traversal is disabled on the session screen on purpose; Tab toggles the - side panel. +- Tab traversal is disabled in the session player on purpose; Tab toggles + chat & activity. - After editing constants (ports, thresholds), grep to confirm the value landed. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 26a53f8..12e46aa 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -44,7 +44,7 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format: - **Dedicated server**: `watch-together-server`, a separate program in the release tarball, for an always-on box. See the README. -Friends join either way with the Join card on Home. +Friends join either way with Join in the strip at the top of the window. ## One session, start to finish diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 64f5b98..7870d8d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,4 +1,4 @@ -# Protocol (proto = 8) +# Protocol (proto = 9) Wire format only. Behavior (gate, clock, timeouts) is in [ARCHITECTURE.md](ARCHITECTURE.md). Source of truth: @@ -23,12 +23,13 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). serves the host's file (Range table in ARCHITECTURE.md). `GET /j///` relays the current Jellyfin media (see "Jellyfin media" below). `/r///...` is the relay of a - member's media (see "Relay" below). + member's media (see "Relay" below). `GET /a///` serves + a member's picture (see "Avatars" below). ## Client to server ``` -{"t":"hello","proto":7,"secret":"","user":{"name":"","avatar":""}, +{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""}, "media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...", "resume":""} {"t":"ready","ready":true,"source":"manual"} source: manual | auto @@ -42,6 +43,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). {"t":"ping","c":1767225600000} {"t":"chat","text":"..."} {"t":"kick","target":"","reason":""} +{"t":"profile","name":"...","avatar":""} ``` Field notes: @@ -68,8 +70,13 @@ Field notes: Sticky: a report without `jf` keeps the last one. At most 8 entries, keys up to 64 characters; others are dropped. - `ping.c`: the client's clock in epoch ms; answered with `pong`. -- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is - trimmed, empty becomes `?`, cut to 32 characters. +- `user.avatar`: base64 PNG, at most 256 KiB of characters (else + `rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32 + characters. +- `profile`: changes this member's name and/or picture mid-session. A field + that is absent (or null) stays as it is; `avatar: ""` removes the picture. + Same rules as `hello.user`; a too large avatar gets `err` + `rejected: avatar too large` (the socket stays open, nothing changes). - `chat.text` is cut to 500 characters. - `swap.media.url` is the link; `name`, `size`, `duration` are informational. `swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is @@ -85,7 +92,7 @@ Field notes: {"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3, "serverTime":"","mediaId":1, "media":{name,size,duration,url?,jellyfin?}|absent, - "members":[{"id","name","avatar","ready","inSet","buffered","stale","jf"?}], + "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?}], "seekBy":"m1","seekSeq":3,"relay":true} {"t":"pong","c":1767225600000,"s":1767225600012} {"t":"feed","actor":"","action":"","target":"","reason":"","at":""} @@ -111,19 +118,36 @@ Field notes: it. `seekBy` (member id) and `seekSeq` are present only while the current `eventId` came from a seek. `relay` (omitted when false): the server can relay the current Jellyfin media. `members[].jf` is the member's last - `report.jf`, omitted when it never sent one. + `report.jf`, omitted when it never sent one. `members[].avatarHash` + (omitted when the member has no picture) names the picture's bytes (see + "Avatars"). - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms (the clock `serverTime` uses). With `t3` the local receive time, the offset is `s - (c + t3) / 2`, accurate to half the round trip. - `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`, `force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`, - `seek`, `pause`, `resume`, `finished`, `chat`. `target` and `reason` are optional; - `reason` carries `auto`/`manual` for ready and `silent too long` or - `no signal` for leave/stale. + `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target` + and `reason` are optional; `reason` carries `auto`/`manual` for ready and + `silent too long` or `no signal` for leave/stale. `rename`: `actor` is the + new name, `target` the old one. `avatar`: the actor changed only the + picture. - Chat is delivered twice: a `chat` frame with the text and a textless `feed` with `action:"chat"`. Render the `chat` frame only; the reference client drops the textless feed entry. +## Avatars + +- The picture is sent as base64 in `hello.user.avatar` or `profile.avatar`; + `state` carries only its hash: the first 16 hex characters of the sha256 + of the decoded bytes. An avatar that is not valid base64, or whose bytes + are not a PNG, JPEG, WebP or GIF (by magic bytes), counts as none. +- `GET /a///`: the bytes with their image content type, + `cache-control: private, max-age=31536000, immutable` (a hash names fixed + bytes; clients fetch each hash once, from the host they dialled). + Errors: 401 bad token, 403 kicked, 404 no current member has that + picture, 410 unknown session. Pictures go with their member (leave, + change, removal). + ## Jellyfin media ``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dd74d33..1618de7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,8 +42,6 @@ off by deleting it in the same commit as the code. Wire changes also edit outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no OOM, no stuck session, drift recovers. - Chat history pane (today: activity log only). - - Avatar caching: serve avatar bytes once by hash instead of base64 in - every `state` broadcast. - The `state` broadcast carries the full media URL, which can include an `api_key` (recognised Jellyfin links become refs; other links do not). - TLS: document a reverse proxy in front of the server. diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index aa25572..80308ff 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -1,5 +1,5 @@ abstract class RelayConstants { - static const int protoVersion = 8; + static const int protoVersion = 9; // Relay (`/r/...`): a member's bytes through the server's buffer. static const int relaySliceBytes = 1 << 20; @@ -32,13 +32,11 @@ abstract class RelayConstants { static const int stateHeartbeatSecs = 1; static const int pingIntervalSecs = 5; - // Base64-char count of the avatar string, enforced at hello. 256 KiB - // leaves wide headroom over any 128x128 pipeline PNG (raw bitmap - // <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size - // for private-server use. PNG keeps transparency; JPEG did not. - // Trade: avatars ride in the 1 Hz state broadcast, so worst-case host - // uplink scales with this cap. Follow-up (not now): send avatar bytes - // once, reference by hash afterwards. + // Base64-char count of the avatar string, enforced at hello and + // profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG + // (raw bitmap <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate + // over-size for private-server use. PNG keeps transparency; JPEG did + // not. Only the hash rides in `state`; the bytes are fetched once. static const int maxAvatarBytes = 256 * 1024; static const int maxNameLength = 32; static const int maxChatLength = 500; diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index 428835e..fb41424 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -18,6 +18,14 @@ int finiteInt(Object? value, String field, {int fallback = 0}) { return n.toInt(); } +/// A member name as shown to everyone: trimmed, cut to +/// [RelayConstants.maxNameLength], `?` when empty. +String cleanMemberName(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + return trimmed.substring(0, trimmed.length.clamp(0, RelayConstants.maxNameLength)); +} + class HelloUser { final String name; final String avatar; @@ -450,10 +458,32 @@ class ChatMessage { } } +/// Changes this member's name and/or picture mid-session. Null fields stay +/// as they are; an empty [avatar] removes the picture. +class ProfileIntent { + final String? name; + /// Base64 PNG, like `hello.user.avatar`. + final String? avatar; + const ProfileIntent({this.name, this.avatar}); + + Map toJson() => { + 't': 'profile', + if (name != null) 'name': name, + if (avatar != null) 'avatar': avatar, + }; + + factory ProfileIntent.fromJson(Map json) { + if (json['t'] != 'profile') throw const ProtocolException('not a profile intent'); + return ProfileIntent(name: json['name'] as String?, avatar: json['avatar'] as String?); + } +} + class MemberView { final String id; final String name; - final String avatar; + /// Content hash of the member's picture, fetched at `/a/...`; empty when + /// none. + final String avatarHash; final bool ready; final bool inSet; final double buffered; @@ -463,7 +493,7 @@ class MemberView { const MemberView({ required this.id, required this.name, - this.avatar = '', + this.avatarHash = '', required this.ready, required this.inSet, required this.buffered, @@ -474,7 +504,7 @@ class MemberView { Map toJson() => { 'id': id, 'name': name, - 'avatar': avatar, + if (avatarHash.isNotEmpty) 'avatarHash': avatarHash, 'ready': ready, 'inSet': inSet, 'buffered': buffered, @@ -485,7 +515,7 @@ class MemberView { factory MemberView.fromJson(Map json) => MemberView( id: (json['id'] as String?) ?? '', name: (json['name'] as String?) ?? '', - avatar: (json['avatar'] as String?) ?? '', + avatarHash: (json['avatarHash'] as String?) ?? '', ready: (json['ready'] as bool?) ?? false, inSet: (json['inSet'] as bool?) ?? false, buffered: finiteDouble(json['buffered'], 'buffered'), diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index c912f56..c9f5a4d 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -1,3 +1,8 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + import 'constants.dart'; import 'protocol.dart'; @@ -14,11 +19,34 @@ Correction correctionFor({required double ownPosition, required double serverPos return Correction.none; } +/// Content hash of a picture: the first 16 hex chars of its sha256. +String avatarHashOf(List bytes) => sha256.convert(bytes).toString().substring(0, 16); + +/// `(type, subtype)` of a picture by its magic bytes; null for anything +/// that is not PNG, JPEG, WebP or GIF (those are never stored or served). +(String, String)? avatarImageType(List b) { + bool at(int i, List sig) { + if (b.length < i + sig.length) return false; + for (var k = 0; k < sig.length; k++) { + if (b[i + k] != sig[k]) return false; + } + return true; + } + + if (at(0, const [0x89, 0x50, 0x4E, 0x47])) return ('image', 'png'); + if (at(0, const [0xFF, 0xD8, 0xFF])) return ('image', 'jpeg'); + if (at(0, const [0x52, 0x49, 0x46, 0x46]) && at(8, const [0x57, 0x45, 0x42, 0x50])) return ('image', 'webp'); + if (at(0, const [0x47, 0x49, 0x46, 0x38])) return ('image', 'gif'); + return null; +} + class EngineMember { final String id; final String token; String name; - String avatar; + /// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast. + Uint8List? avatarBytes; + String avatarHash = ''; bool ready = false; bool inSet = false; double buffered = 0; @@ -29,14 +57,35 @@ class EngineMember { required this.id, required this.token, required this.name, - this.avatar = '', + String avatar = '', required DateTime now, - }) : lastSeen = now; + }) : lastSeen = now { + setAvatar(avatar); + } + + /// Sets the picture from base64 (empty or undecodable: none). Returns + /// whether it changed. + bool setAvatar(String base64) { + Uint8List? bytes; + if (base64.isNotEmpty) { + try { + bytes = base64Decode(base64); + } on FormatException { + bytes = null; + } + } + if (bytes != null && avatarImageType(bytes) == null) bytes = null; + final hash = bytes == null || bytes.isEmpty ? '' : avatarHashOf(bytes); + if (hash == avatarHash) return false; + avatarHash = hash; + avatarBytes = hash.isEmpty ? null : bytes; + return true; + } MemberView view({required bool stale}) => MemberView( id: id, name: name, - avatar: avatar, + avatarHash: avatarHash, ready: ready, inSet: inSet, buffered: buffered, @@ -129,6 +178,32 @@ class SyncEngine { return id; } + /// Name and/or picture change ([ProfileIntent]); null leaves it, an + /// empty [avatar] removes the picture. [name] is already cleaned. + void applyProfile(String token, {String? name, String? avatar}) { + final member = members[token]; + if (member == null) return; + _seen(member); + final oldName = member.name; + final renamed = name != null && name != oldName; + if (renamed) member.name = name; + final pictured = avatar != null && member.setAvatar(avatar); + if (renamed) { + feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now)); + } else if (pictured) { + feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now)); + } + } + + /// Picture bytes of a current member by [EngineMember.avatarHash]. + Uint8List? avatarFor(String hash) { + if (hash.isEmpty) return null; + for (final member in members.values) { + if (member.avatarHash == hash) return member.avatarBytes; + } + return null; + } + /// A reconnect took over [token]'s member (see `hello.resume`). void applyResume(String token) { final member = members[token]; diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 81a64ad..467a3b3 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -537,4 +537,58 @@ void main() { expect(s.seekBy, isNull); }); }); + + group('profile + avatars', () { + // Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped. + final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List.generate(60, (i) => i)]); + final other = base64Encode([0xFF, 0xD8, 0xFF, ...List.generate(61, (i) => 255 - i)]); + + test('join keeps bytes, state carries only the hash', () { + engine.applyJoin('a', 'alice', png); + final hash = engine.members['a']!.avatarHash; + expect(hash, hasLength(16)); + expect(engine.avatarFor(hash), base64Decode(png)); + final view = engine.broadcast().members.single; + expect(view.avatarHash, hash); + expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(png))); + join('b', 'bob'); + expect(engine.members['b']!.avatarHash, isEmpty); + engine.applyLeave('a'); + expect(engine.avatarFor(hash), isNull, reason: 'dropped with the member'); + }); + + test('a picture that is not an image is dropped', () { + final e = SyncEngine(); + e.applyJoin('x', 'x', base64Encode(List.generate(64, (i) => i))); + expect(e.members['x']!.avatarHash, isEmpty); + expect(avatarImageType([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), ('image', 'webp')); + }); + + test('rename feeds rename (actor new, target old); picture only feeds avatar', () { + engine.applyJoin('a', 'alice', png); + engine.feed.drain(); + engine.applyProfile('a', name: 'bob'); + var feed = engine.feed.drain().single; + expect(feed.action, 'rename'); + expect(feed.actor, 'bob'); + expect(feed.target, 'alice'); + + engine.applyProfile('a', avatar: other); + feed = engine.feed.drain().single; + expect(feed.action, 'avatar'); + expect(feed.actor, 'bob'); + expect(engine.members['a']!.avatarHash, avatarHashOf(base64Decode(other))); + + engine.applyProfile('a', name: 'carol', avatar: ''); + feed = engine.feed.drain().single; + expect(feed.action, 'rename'); + expect(engine.members['a']!.avatarHash, isEmpty); + expect(engine.members['a']!.avatarBytes, isNull); + + engine.applyProfile('a', name: 'carol', avatar: ''); + expect(engine.feed.drain(), isEmpty, reason: 'nothing changed'); + engine.applyProfile('a', avatar: 'not base64!'); + expect(engine.members['a']!.avatarHash, isEmpty); + }); + }); } diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 1c08031..b19b373 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -159,6 +159,37 @@ class ServerHub { } } +/// `GET /a///`: a current member's picture by its +/// content hash, for members (valid, unkicked token) only. The bytes for a +/// hash never change, so clients may cache them for good. +void mountAvatarRoute(RelicApp app, ServerHub hub) { + Response status(int code) => Response(code, body: Body.fromString('status $code')); + app.get('/a/:sid/:token/:hash', (final req) { + final sid = req.pathParameters.raw[#sid] ?? ''; + final token = req.pathParameters.raw[#token] ?? ''; + final hash = req.pathParameters.raw[#hash] ?? ''; + final session = hub.sessions.get(sid); + if (session == null) return status(410); + switch (session.gate(token)) { + case MediaGate.unauthorized: + return status(401); + case MediaGate.forbidden: + return status(403); + default: + break; + } + final bytes = hub.engine.avatarFor(hash); + final type = bytes == null ? null : avatarImageType(bytes); + if (bytes == null || type == null) return status(404); + return Response.ok( + body: Body.fromData(bytes, mimeType: MimeType(type.$1, type.$2)), + headers: Headers.build((h) { + h['cache-control'] = ['private, max-age=31536000, immutable']; + }), + ); + }); +} + /// Media names are shown to everyone and must never carry a URL (api_key etc.). /// A URL-looking name is reduced to its last path segment, no query/fragment. String sanitizeMediaName(String name) { @@ -358,6 +389,19 @@ void mountSyncRoute( mediaId: hub.engine.mediaId); } hub.changed(); + case 'profile': + if (token == null || viewer == null) return; + final intent = ProfileIntent.fromJson(decoded); + final avatar = intent.avatar; + if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) { + wsErr(ws, 'rejected: avatar too large'); + return; + } + final name = intent.name == null ? null : cleanMemberName(intent.name!); + // Chat and kick name the sender by its token's name. + if (name != null) viewer.name = name; + hub.engine.applyProfile(token, name: name, avatar: avatar); + hub.changed(); case 'report': if (token == null) return; final report = ReportMessage.fromJson(decoded); @@ -470,7 +514,7 @@ void mountSyncRoute( await close(); return; } - memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar); + memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar); } session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey); final activeToken = token; diff --git a/packages/server/lib/src/session.dart b/packages/server/lib/src/session.dart index b12f236..a781191 100644 --- a/packages/server/lib/src/session.dart +++ b/packages/server/lib/src/session.dart @@ -7,15 +7,15 @@ enum MediaGate { ok, gone, unauthorized, forbidden, offline } class ViewerToken { final String token; - final String name; - final String avatar; + /// Cleaned ([cleanMemberName]); follows `profile` renames. + String name; bool kicked = false; /// Sent the server's host key in hello: the hosting app's own client. bool isHost = false; int bytesServed = 0; DateTime lastSeen; Future Function(String reason)? onKick; - ViewerToken({required this.token, required this.name, this.avatar = ''}) + ViewerToken({required this.token, required this.name}) : lastSeen = DateTime.now().toUtc(); bool isStale(Duration timeout, [DateTime? now]) => @@ -53,18 +53,14 @@ class Session { Session({required this.id, required this.secret}); + /// The avatar is only checked here; the engine keeps the picture. String issueToken(String name, String avatar) { if (avatar.length > RelayConstants.maxAvatarBytes) { throw const AuthException('avatar too large'); } - final trimmed = name.trim(); - final cleanName = trimmed.isEmpty - ? '?' - : trimmed.substring( - 0, trimmed.length.clamp(0, RelayConstants.maxNameLength)); final bytes = List.generate(16, (_) => _random.nextInt(256)); final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - tokens[token] = ViewerToken(token: token, name: cleanName, avatar: avatar); + tokens[token] = ViewerToken(token: token, name: cleanMemberName(name)); return token; } diff --git a/packages/server/lib/src/sync_server.dart b/packages/server/lib/src/sync_server.dart index 899a6a0..84e5d71 100644 --- a/packages/server/lib/src/sync_server.dart +++ b/packages/server/lib/src/sync_server.dart @@ -64,6 +64,7 @@ class SyncServer { }); mountSessionMediaRoute(_app, sessions); mountRelayRoutes(_app, sessions, () => hub.relay); + mountAvatarRoute(_app, hub); mountJellyfinRelayRoute( _app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient()); // advertiseHost is only an explicit override; otherwise echo what the client dialled. diff --git a/packages/server/test/server_auth_test.dart b/packages/server/test/server_auth_test.dart index 2addafe..42e124b 100644 --- a/packages/server/test/server_auth_test.dart +++ b/packages/server/test/server_auth_test.dart @@ -389,5 +389,93 @@ void main() { await ch.sink.close(); } catch (_) {} }); + + test('profile: rename + avatar feed, state carries hash, /a gated', () async { + final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List.generate(296, (i) => (i * 7) % 256)]); + final ch = WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final messages = >[]; + final sub = ch.stream.listen( + (m) => messages.add(Map.from(jsonDecode('$m') as Map))); + Future> waitFor(bool Function(Map) test) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + for (final m in messages) { + if (test(m)) return m; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('timed out; got $messages'); + } + + Map member(Map state) => + Map.from((state['members'] as List).single as Map); + Future avatarGet(String token, String hash) async => + (await http.get('127.0.0.1', server.boundPort, '/a/default/$token/$hash')).close(); + + try { + ch.sink.add(jsonEncode(HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: HelloUser(name: 'alice', avatar: base64Encode(png)), + media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson())); + final welcome = await waitFor((m) => m['t'] == 'welcome'); + final token = welcome['token'] as String; + final first = member(Map.from(welcome['state'] as Map)); + expect(first.containsKey('avatar'), isFalse, reason: 'no base64 in state'); + final hash = first['avatarHash'] as String; + expect(hash, avatarHashOf(png)); + + final ok = await avatarGet(token, hash); + expect(ok.statusCode, 200); + expect(ok.headers.contentType?.mimeType, 'image/png'); + expect(ok.headers.value('cache-control'), contains('max-age')); + expect(await ok.expand((c) => c).toList(), png); + final unknown = await avatarGet(token, '0123456789abcdef'); + expect(unknown.statusCode, 404); + await unknown.drain(); + final noToken = await avatarGet('nope', hash); + expect(noToken.statusCode, 401); + await noToken.drain(); + + // Rename: feed rename (actor new, target old); chat uses the new name. + messages.clear(); + ch.sink.add(jsonEncode(const ProfileIntent(name: ' bob ').toJson())); + final rename = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'rename'); + expect(rename['actor'], 'bob'); + expect(rename['target'], 'alice'); + final renamed = await waitFor((m) => m['t'] == 'state' && member(m)['name'] == 'bob'); + expect(member(renamed)['avatarHash'], hash, reason: 'picture unchanged'); + ch.sink.add(jsonEncode(const ChatMessage(text: 'hi').toJson())); + expect((await waitFor((m) => m['t'] == 'chat'))['from'], 'bob'); + + // Picture only: feed avatar; removing it drops the hash and the bytes. + messages.clear(); + ch.sink.add(jsonEncode(const ProfileIntent(avatar: '').toJson())); + final pic = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'avatar'); + expect(pic['actor'], 'bob'); + await waitFor((m) => m['t'] == 'state' && !member(m).containsKey('avatarHash')); + final gone = await avatarGet(token, hash); + expect(gone.statusCode, 404); + await gone.drain(); + + // Too large: rejected, nothing changes. + messages.clear(); + ch.sink.add(jsonEncode( + ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson())); + final err = await waitFor((m) => m['t'] == 'err'); + expect(err['message'], 'rejected: avatar too large'); + + await server.session.kick(token); + final kicked = await avatarGet(token, hash); + expect(kicked.statusCode, 403); + await kicked.drain(); + } finally { + await sub.cancel(); + try { + await ch.sink.close(); + } catch (_) {} + } + }); }); } diff --git a/packages/server/test/session_test.dart b/packages/server/test/session_test.dart index 462ff7c..df51db2 100644 --- a/packages/server/test/session_test.dart +++ b/packages/server/test/session_test.dart @@ -47,7 +47,7 @@ void main() { final s = Session(id: 'default', secret: 'shh'); final png = 'x' * (64 * 1024); expect(s.issueToken('alice', png), isNotEmpty); - expect(s.tokens.values.single.avatar, hasLength(64 * 1024)); + expect(s.tokens, hasLength(1)); }); test('names truncated to cap, blank becomes ?', () { @@ -80,8 +80,21 @@ void main() { }); group('core welcome + proto', () { - test('proto 8: relayed media', () { - expect(RelayConstants.protoVersion, 8); + test('proto 9: avatars by hash, profile intent', () { + expect(RelayConstants.protoVersion, 9); + const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false); + expect(v.toJson().containsKey('avatarHash'), isFalse); + expect(v.toJson().containsKey('avatar'), isFalse); + const h = MemberView( + id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false); + expect(MemberView.fromJson(Map.from(h.toJson())).avatarHash, 'abc'); + expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'}); + final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''}); + expect(p.name, isNull); + expect(p.avatar, ''); + }); + + test('relayed media', () { const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true)); final back = HelloMedia.fromJson(Map.from(m.toJson())); expect(back.relay!.source, 'm1');