From ae4edc29e8654ae6a6cf45d7aaca40afd97f8b49 Mon Sep 17 00:00:00 2001 From: foxyz Date: Wed, 7 Oct 2026 02:38:24 +0300 Subject: [PATCH] Jellyfin end to end: per-viewer playback, progress, episodes, swap browser - Host/swap send refs only; pasted Jellyfin links become refs; the host hands the relay its own app-obtained credential. - Each viewer resolves with their own account (token in a header) or via the relay, else a clear blocked card with Link account. - Per-account progress reporting (Playing/Progress/Stopped), 401 marks the account expired. - Previous/next episode in the controls (< > / Shift+P/N), floating Next episode pill near the end, auto-advance countdown (one designated swapper), track carry-over per series. - Swap panel uses the shared MediaPicker; browser location remembered per server and opens on the current series. - Fix: states are applied one at a time (a stale pause could land after a newer play). Co-Authored-By: Claude Opus 5.5 --- README.md | 50 +- apps/desktop/lib/src/jellyfin/accounts.dart | 25 +- .../lib/src/jellyfin/browse_scope.dart | 88 +++ apps/desktop/lib/src/jellyfin/browser.dart | 35 +- .../lib/src/jellyfin/picker_source.dart | 4 + apps/desktop/lib/src/jellyfin/progress.dart | 134 ++++ .../lib/src/jellyfin/session_media.dart | 346 ++++++++++ .../desktop/lib/src/jellyfin/track_prefs.dart | 189 ++++++ .../lib/src/playback/playback_controller.dart | 82 ++- .../lib/src/screens/home/connect_flow.dart | 20 +- .../lib/src/screens/home/host_tab.dart | 23 +- .../lib/src/screens/home/media_picker.dart | 25 +- .../lib/src/screens/home/settings_page.dart | 22 + .../src/screens/session/jellyfin_widgets.dart | 328 ++++++++++ .../lib/src/screens/session/swap_panel.dart | 136 ++++ .../lib/src/screens/session_screen.dart | 255 ++++++-- .../lib/src/session/session_coordinator.dart | 551 ++++++++++++++-- .../desktop/lib/src/state/server_isolate.dart | 21 + apps/desktop/lib/src/state/session_state.dart | 17 +- apps/desktop/lib/src/state/settings.dart | 10 + apps/desktop/lib/src/sync/sync_client.dart | 7 + apps/desktop/test/jellyfin_accounts_test.dart | 18 +- apps/desktop/test/jellyfin_e2e_test.dart | 448 +++++++++++++ apps/desktop/test/jellyfin_session_test.dart | 591 ++++++++++++++++++ apps/desktop/test/server_isolate_test.dart | 56 ++ docs/ARCHITECTURE.md | 93 ++- docs/DEVELOPMENT.md | 3 +- docs/HOW-IT-WORKS.md | 14 +- docs/ROADMAP.md | 36 +- packages/core/lib/src/jellyfin/client.dart | 27 + packages/core/test/jellyfin_client_test.dart | 16 + packages/server/lib/src/serve.dart | 21 +- packages/server/test/serve_isolate_test.dart | 25 + 33 files changed, 3541 insertions(+), 175 deletions(-) create mode 100644 apps/desktop/lib/src/jellyfin/progress.dart create mode 100644 apps/desktop/lib/src/jellyfin/session_media.dart create mode 100644 apps/desktop/lib/src/jellyfin/track_prefs.dart create mode 100644 apps/desktop/lib/src/screens/session/jellyfin_widgets.dart create mode 100644 apps/desktop/lib/src/screens/session/swap_panel.dart create mode 100644 apps/desktop/test/jellyfin_e2e_test.dart create mode 100644 apps/desktop/test/jellyfin_session_test.dart diff --git a/README.md b/README.md index 5d5b6aa..dd415ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # watch-together Watch a video with friends, in sync, with the quality of a real mpv player. -Paste a Jellyfin link, any direct video link, or pick a file on your own disk; +Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk; everyone's play, pause and seek stay together. Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor. @@ -83,12 +83,14 @@ x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host. Profile, hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), -updates, video shaders (see below), and the keyboard shortcuts (read-only). +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. Keys in a session: Space = ready / unready, Tab = side panel (people, log, chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close -panel or ask to leave, Ctrl+R = reload video, Ctrl+1..6 = Anime4K mode A, B, +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. ## Ports @@ -122,11 +124,43 @@ 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. -For now a hosted Jellyfin pick is shared as a stream link carrying the -host's sign-in, so everyone watches through the host's account. Per-person -playback, watched status and progress sync are planned: see -[docs/ROADMAP.md](docs/ROADMAP.md). Pasting a Jellyfin direct-play link -still works like any link. +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 +same when the app can reach that Jellyfin; otherwise it is shared as a +plain link. + +Watching: +- Everyone with their own account on that Jellyfin server plays the file + directly with their own sign-in. Jellyfin tracks progress per account: + your watched state, Continue watching and resume point follow what you + saw (reported on start, every 10 s, on pause, resume, seek and track + change, and when it stops). +- Friends without an account watch through the host: the server fetches + the file with the host's sign-in and passes it on (only the current item, + only to people in the session). They show "via host" in the member list. + A dedicated server relays when started with `--jellyfin ` (see + [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay). +- Without either, the app says "This needs a Jellyfin account on ..." with + a button to link one; it plays as soon as you are signed in. + +Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, +Shift+P / Shift+N) put up the previous or next episode for everyone. In +the last two minutes a "Next episode" button floats in the corner. When an +episode ends, a card shows what is next with Play next; with "Play the +next episode automatically" (Settings, on by default) it counts down 10 s +first, and Cancel stops it on your screen (seeking back stops it for +everyone). Anyone with an account can put the next one up; friends +watching via the host cannot. + +Audio and subtitles: the subtitles button in the controls picks tracks. +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. File is offered to the host only. ## Video quality and shaders diff --git a/apps/desktop/lib/src/jellyfin/accounts.dart b/apps/desktop/lib/src/jellyfin/accounts.dart index e26d305..dc26ce4 100644 --- a/apps/desktop/lib/src/jellyfin/accounts.dart +++ b/apps/desktop/lib/src/jellyfin/accounts.dart @@ -23,6 +23,7 @@ const _keyDeviceId = 'deviceId'; const _keyAccounts = 'accounts'; const _keyDefault = 'defaultServer'; const _keyLastPick = 'lastPick'; +const _keyBrowsePrefix = 'browse:'; /// One signed-in user on one server. class JellyfinAccount { @@ -124,10 +125,12 @@ abstract class JellyfinStore { } class HiveJellyfinStore implements JellyfinStore { + HiveJellyfinStore({this.boxName = jellyfinBoxName}); + final String boxName; Box? _box; @override - Future open() async => _box ??= await Hive.openBox(jellyfinBoxName); + Future open() async => _box ??= await Hive.openBox(boxName); @override Object? get(String key) => _box?.get(key); @@ -461,6 +464,26 @@ class JellyfinAccounts extends ChangeNotifier { } } + /// The browser's last location on [serverId] (encoded route stack, see + /// `browse_scope.dart`), restored by every browser and across restarts. + Object? browseLocation(String serverId) { + try { + final v = _store.get('$_keyBrowsePrefix$serverId'); + return v is String ? _tryJson(v) : null; + } catch (_) { + return null; + } + } + + Future saveBrowseLocation(String serverId, Object? location) async { + try { + await _store.put( + '$_keyBrowsePrefix$serverId', location == null ? null : jsonEncode(location)); + } catch (e) { + debugPrint('save browse location failed: $e'); + } + } + // --------------------------------------------------------------- cache /// Short-lived memo for browse calls, so going back does not refetch. diff --git a/apps/desktop/lib/src/jellyfin/browse_scope.dart b/apps/desktop/lib/src/jellyfin/browse_scope.dart index 319334e..044af4c 100644 --- a/apps/desktop/lib/src/jellyfin/browse_scope.dart +++ b/apps/desktop/lib/src/jellyfin/browse_scope.dart @@ -53,6 +53,94 @@ final class DetailRoute extends BrowseRoute { String get key => 'item:${item.id}'; } +/// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only +/// ids, names and the fields the pages start from (each page loads the +/// full item itself). +List> encodeBrowseStack(List stack) => [ + for (final r in stack) + switch (r) { + HomeRoute() => {'r': 'home'}, + LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)}, + SeriesRoute(:final series, :final seasonId) => { + 'r': 'series', + ..._itemJson(series), + 'seasonId': ?seasonId, + }, + DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)}, + } + ]; + +/// Inverse of [encodeBrowseStack]; always starts with Home. Null when +/// [raw] is not a stack. +List? decodeBrowseStack(Object? raw) { + if (raw is! List) return null; + final out = [const HomeRoute()]; + for (final e in raw) { + if (e is! Map) return null; + final item = _itemFrom(e); + final route = switch (e['r']) { + 'home' => null, + 'lib' when item != null => LibraryRoute(item), + 'series' when item != null => + SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null), + 'item' when item != null => DetailRoute(item), + _ => null, + }; + if (route != null && out.every((r) => r.key != route.key)) out.add(route); + } + return out; +} + +Map _itemJson(JellyfinItem i) => { + 'id': i.id, + 'name': i.name, + 'type': i.type, + if (i.isFolder) 'isFolder': true, + 'collectionType': ?i.collectionType, + 'seriesId': ?i.seriesId, + 'seriesName': ?i.seriesName, + 'seasonId': ?i.seasonId, + 'indexNumber': ?i.indexNumber, + 'parentIndexNumber': ?i.parentIndexNumber, + }; + +JellyfinItem? _itemFrom(Map e) { + String? s(String k) => e[k] is String && (e[k] as String).isNotEmpty ? e[k] as String : null; + final id = s('id'); + if (id == null) return null; + return JellyfinItem( + id: id, + name: s('name') ?? '', + type: s('type') ?? '', + isFolder: e['isFolder'] == true, + collectionType: s('collectionType'), + seriesId: s('seriesId'), + seriesName: s('seriesName'), + seasonId: e['r'] == 'series' ? null : s('seasonId'), + indexNumber: e['indexNumber'] is int ? e['indexNumber'] as int : null, + parentIndexNumber: e['parentIndexNumber'] is int ? e['parentIndexNumber'] as int : null, + ); +} + +/// Where a browser opens instead of the remembered location: a server and +/// a route stack (e.g. the series of the episode playing). +class BrowseStart { + final String serverId; + final List stack; + const BrowseStart(this.serverId, this.stack); + + /// The series page of [episode] with its season selected. + static BrowseStart? seriesOf(String serverId, JellyfinItem episode) { + final sid = episode.seriesId; + if (sid == null) return null; + return BrowseStart(serverId, [ + const HomeRoute(), + SeriesRoute(JellyfinItem(id: sid, name: episode.seriesName ?? '', type: 'Series'), + seasonId: episode.seasonId), + ]); + } +} + /// What every browser page needs, provided by [JellyfinBrowser]. class BrowseScope extends InheritedWidget { final JellyfinAccounts accounts; diff --git a/apps/desktop/lib/src/jellyfin/browser.dart b/apps/desktop/lib/src/jellyfin/browser.dart index 08e59b2..9edaa9b 100644 --- a/apps/desktop/lib/src/jellyfin/browser.dart +++ b/apps/desktop/lib/src/jellyfin/browser.dart @@ -36,7 +36,11 @@ class JellyfinBrowser extends StatefulWidget { final bool expanded; final VoidCallback? onOpenIntegrations; final Duration searchDebounce; + /// Opens here instead of the remembered location (the series of the + /// episode playing, in the session's swap panel). + final BrowseStart? start; const JellyfinBrowser({ + this.start, super.key, required this.accounts, required this.onPick, @@ -67,12 +71,32 @@ class _JellyfinBrowserState extends State { @override void initState() { super.initState(); - _serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null - ? widget.selected!.serverId - : _accounts.defaultServerId; + final start = widget.start; + if (start != null && _accounts.account(start.serverId) != null) { + _serverId = start.serverId; + _stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack; + _remember(); + } else { + _serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null + ? widget.selected!.serverId + : _accounts.defaultServerId; + _stack = _restored(_serverId); + } _accounts.addListener(_changed); } + /// The remembered location on [serverId], else Home. + List _restored(String? serverId) => + (serverId == null ? null : decodeBrowseStack(_accounts.browseLocation(serverId))) ?? + const [HomeRoute()]; + + /// Every navigation is saved per server (Hive), so the next browser + /// (Host tab, swap panel, next start) opens where this one was. + void _remember() { + final id = _account?.serverId; + if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack))); + } + @override void didUpdateWidget(JellyfinBrowser old) { super.didUpdateWidget(old); @@ -104,7 +128,7 @@ class _JellyfinBrowserState extends State { if (serverId == _account?.serverId) return; setState(() { _serverId = serverId; - _stack = const [HomeRoute()]; + _stack = _restored(serverId); _peek.value = null; _clearSearch(); }); @@ -117,12 +141,14 @@ class _JellyfinBrowserState extends State { _clearSearch(); if (_stack.last.key != route.key) _stack = [..._stack, route]; }); + _remember(); _body.requestFocus(); } bool _back() { if (_stack.length <= 1) return false; setState(() => _stack = _stack.sublist(0, _stack.length - 1)); + _remember(); _body.requestFocus(); return true; } @@ -132,6 +158,7 @@ class _JellyfinBrowserState extends State { _clearSearch(); _stack = _stack.sublist(0, index + 1); }); + _remember(); _body.requestFocus(); } diff --git a/apps/desktop/lib/src/jellyfin/picker_source.dart b/apps/desktop/lib/src/jellyfin/picker_source.dart index 8ce066b..ff6b3cb 100644 --- a/apps/desktop/lib/src/jellyfin/picker_source.dart +++ b/apps/desktop/lib/src/jellyfin/picker_source.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../screens/home/picked_media.dart'; import '../screens/home/ui.dart'; import 'accounts.dart'; +import 'browse_scope.dart'; import 'browser.dart'; import 'sign_in_panel.dart'; @@ -15,8 +16,10 @@ class JellyfinPickerSource extends StatefulWidget { final VoidCallback? onOpenIntegrations; final VoidCallback? onToggleExpanded; final bool expanded; + final BrowseStart? start; const JellyfinPickerSource({ super.key, + this.start, required this.accounts, required this.onPick, this.selected, @@ -54,6 +57,7 @@ class _JellyfinPickerSourceState extends State { onOpenIntegrations: widget.onOpenIntegrations, onToggleExpanded: widget.onToggleExpanded, expanded: widget.expanded, + start: widget.start, ); }, ); diff --git a/apps/desktop/lib/src/jellyfin/progress.dart b/apps/desktop/lib/src/jellyfin/progress.dart new file mode 100644 index 0000000..cd75971 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/progress.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:sync_relay_core/core.dart'; + +/// What the player shows, sampled about once a second. [aid] / [sid] are +/// mpv track ids (null = none / subtitles off). +class PlayerSnapshot { + final double position; + final bool paused; + final int? aid; + final int? sid; + const PlayerSnapshot({required this.position, required this.paused, this.aid, this.sid}); + + @override + String toString() => 'PlayerSnapshot($position, paused: $paused, aid: $aid, sid: $sid)'; +} + +/// Playing / Progress / Stopped for one item played with this viewer's own +/// account, so Jellyfin's watched state and Continue Watching follow what +/// this viewer saw. Fed a [PlayerSnapshot] every tick ([update]); sends +/// Progress every [interval] and at once on pause, resume, seek (a jump of +/// more than 3 s from where playback should be) or a track change. Reports +/// go out one after another; a failure is logged and handed to [onError] +/// (the session shows one quiet toast) and never touches playback. A 401 +/// calls [onUnauthorized] and ends reporting. +class JellyfinProgressReporter { + final JellyfinClient client; + final String itemId; + final JellyfinMediaSource source; + final String playSessionId; + final Duration interval; + final DateTime Function() _clock; + final void Function(Object error)? onError; + final VoidCallback? onUnauthorized; + + JellyfinProgressReporter({ + required this.client, + required this.itemId, + required this.source, + required this.playSessionId, + this.interval = const Duration(seconds: 10), + this.onError, + this.onUnauthorized, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + Future _chain = Future.value(); + bool _playing = false; + bool _dead = false; + PlayerSnapshot? _last; + DateTime? _lastAt; + DateTime? _lastSentAt; + + /// Between [start] and [stop]. + bool get active => _playing && !_dead; + + /// The report body for [s]: ticks, pause, and mpv's aid/sid mapped to + /// Jellyfin stream indices (-1 = subtitles off). + JellyfinPlaybackReport reportFor(PlayerSnapshot s) => JellyfinPlaybackReport( + itemId: itemId, + mediaSourceId: source.id, + playSessionId: playSessionId, + positionTicks: (s.position * jellyfinTicksPerSecond).round(), + isPaused: s.paused, + audioStreamIndex: s.aid == null + ? null + : source.jellyfinIndexForMpvTrack(JellyfinStreamType.audio, s.aid!), + subtitleStreamIndex: s.sid == null + ? -1 + : source.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, s.sid!), + ); + + /// Playing: once the media is open. Again after [stop] (played to the + /// end, then someone seeked back) starts reporting anew. + void start(PlayerSnapshot s) { + if (_playing || _dead) return; + _playing = true; + _mark(s); + _send('Playing', () => client.reportPlaybackStart(reportFor(s))); + } + + /// Progress when due (see the class doc). + void update(PlayerSnapshot s, {bool force = false}) { + if (!_playing || _dead) return; + final last = _last, lastAt = _lastAt, sentAt = _lastSentAt; + final now = _clock(); + var due = force || last == null || lastAt == null || sentAt == null; + if (!due) { + final elapsed = now.difference(lastAt).inMilliseconds / 1000; + final expected = last.position + (last.paused ? 0 : elapsed); + due = s.paused != last.paused || + s.aid != last.aid || + s.sid != last.sid || + (s.position - expected).abs() > 3 || + now.difference(sentAt) >= interval; + } + _last = s; + _lastAt = now; + if (!due) return; + _lastSentAt = now; + _send('Progress', () => client.reportPlaybackProgress(reportFor(s))); + } + + /// Stopped (swap, leave, kick, window close, played to the end). + /// Completes when it and everything before it went out. + Future stop(PlayerSnapshot s) { + if (!_playing || _dead) return _chain; + _playing = false; + _send('Stopped', () => client.reportPlaybackStopped(reportFor(s))); + return _chain; + } + + void _mark(PlayerSnapshot s) { + _last = s; + _lastAt = _lastSentAt = _clock(); + } + + void _send(String what, Future Function() call) { + _chain = _chain.then((_) async { + if (_dead) return; + try { + await call().timeout(const Duration(seconds: 10)); + } catch (e) { + debugPrint('jellyfin $what report for $itemId failed: $e'); + if (e is JellyfinException && e.isUnauthorized) { + _dead = true; + onUnauthorized?.call(); + } + onError?.call(e); + } + }); + } +} diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart new file mode 100644 index 0000000..5c10ffa --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../screens/home/picked_media.dart'; +import 'accounts.dart'; +import 'format.dart'; + +// Jellyfin media in a session: turning picks and pasted links into refs +// (the wire carries only `{serverId, itemId, ...}`), the hosted server's +// relay credential, and how this viewer plays a ref (own account, relay, +// or neither). + +/// The wire ref of a browser pick. +JellyfinRef refForPick(JellyfinMedia m) => JellyfinRef( + serverId: m.serverId, + itemId: m.itemId, + mediaSourceId: m.mediaSourceId, + title: m.title, + subtitle: m.subtitle.isEmpty ? null : m.subtitle, + runtimeSecs: m.runtime == null ? null : m.runtime!.inMilliseconds / 1000, + ); + +/// The `swap` media for a browser pick: a ref, never a URL. +HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia( + name: m.title.isEmpty ? 'Jellyfin item' : m.title, + size: 0, + duration: 0, + jellyfin: refForPick(m)); + +/// The pick a ref stands for on a linked server (for highlighting it in +/// the browser); null when this install has no account there. +JellyfinMedia? pickForRef(JellyfinAccounts accounts, JellyfinRef ref) { + final a = accounts.account(ref.serverId); + if (a == null) return null; + return JellyfinMedia( + serverId: ref.serverId, + baseUrl: a.baseUrl, + itemId: ref.itemId, + mediaSourceId: ref.mediaSourceId, + title: ref.title, + subtitle: ref.subtitle ?? '', + runtime: ref.runtimeSecs == null + ? null + : Duration(milliseconds: (ref.runtimeSecs! * 1000).round()), + ); +} + +/// The ref in a `state.media` map, or null for other media. +JellyfinRef? refOfStateMedia(Map? media) { + final jf = media?['jellyfin']; + if (jf is! Map) return null; + try { + final ref = JellyfinRef.fromJson(Map.from(jf)); + return ref.serverId.isEmpty || ref.itemId.isEmpty ? null : ref; + } catch (_) { + return null; + } +} + +/// Same item (server, item, source); titles may differ. +bool sameJellyfinItem(JellyfinRef? a, JellyfinRef? b) => + a != null && + b != null && + a.serverId == b.serverId && + a.itemId == b.itemId && + a.mediaSourceId == b.mediaSourceId; + +final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$'); + +/// What a pasted link becomes on the wire. A recognised Jellyfin item link +/// (`parseJellyfinLink`) becomes a ref when its ServerId is known (in the +/// link or from `/System/Info/Public`); its `api_key` is never shared then. +/// Anything else, or a server that does not answer, stays a plain link. +/// The title comes from this install's account on that server when there +/// is one. +Future mediaForLink(JellyfinAccounts accounts, String url, + {Duration timeout = const Duration(seconds: 6)}) async { + final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url); + final link = parseJellyfinLink(url); + if (link == null) return plain; + await accounts.ensureLoaded(); + var serverId = link.serverId; + if (serverId == null || serverId.isEmpty) { + final probe = accounts.anonymousClient(link.baseUrl); + try { + serverId = (await probe.publicInfo().timeout(timeout)).id; + } catch (e) { + debugPrint('jellyfin link: no server id from ${link.baseUrl}: $e'); + return plain; + } finally { + probe.close(); + } + if (serverId.isEmpty) return plain; + } + final src = Uri.tryParse(url)?.queryParameters['mediaSourceId']; + var ref = JellyfinRef( + serverId: serverId, + itemId: link.itemId, + mediaSourceId: src != null && _sourceId.hasMatch(src) && src != link.itemId ? src : null, + ); + final account = accounts.account(serverId); + final client = accounts.clientFor(serverId); + if (account != null && client != null) { + try { + final pick = pickFor(account, await client.item(link.itemId).timeout(timeout)); + ref = refForPick(JellyfinMedia( + serverId: serverId, + baseUrl: account.baseUrl, + itemId: link.itemId, + mediaSourceId: ref.mediaSourceId, + title: pick.title, + subtitle: pick.subtitle, + runtime: pick.runtime, + )); + } catch (e) { + if (e is JellyfinException && e.isUnauthorized) accounts.markExpired(serverId); + } + } + return HelloMedia( + name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref); +} + +/// Display name for a link: last path segment, decoded, never the query +/// (Jellyfin-style links carry api_key there and names are broadcast). +String linkName(String url) { + final uri = Uri.tryParse(url.trim()); + if (uri == null) return 'link'; + final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segs.isNotEmpty) { + try { + return Uri.decodeComponent(segs.last); + } catch (_) { + return segs.last; + } + } + return uri.host.isNotEmpty ? uri.host : 'link'; +} + +/// The hosted server's relay login: the account for [serverId] (the +/// hosted item's server), else the default account, when its token is not +/// known to be expired. The device fields are exactly the ones this app +/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites +/// a token's device from the request header, so the relay must not differ. +JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) { + bool usable(JellyfinAccount? a) => + a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired; + var a = serverId == null ? null : accounts.account(serverId); + if (!usable(a)) a = accounts.defaultAccount; + if (!usable(a)) return null; + final info = accounts.clientInfo; + return JellyfinCredential( + baseUrl: a!.baseUrl, + serverId: a.serverId, + token: a.token, + client: info.client, + device: info.device, + deviceId: info.deviceId, + version: info.version, + ); +} + +/// `/j/...` URL of the current Jellyfin media on the host this client +/// dialled, built like the `/m/...` one. +String relayUrlFor(String host, int port, String token, String itemId) => + 'http://$host:$port/j/default/$token/${Uri.encodeComponent(itemId)}'; + +// ---------------------------------------------------------------- playing + +/// How this viewer plays a ref. +sealed class JellyfinPlan { + const JellyfinPlan(); + + /// `report.jf` for this plan; null when it plays nothing. + bool? get jfAccess; +} + +/// Direct play with this install's account: the static stream, the token +/// in [headers] (not in the URL), progress reported per [info]. +final class OwnPlay extends JellyfinPlan { + final JellyfinClient client; + final JellyfinAccount account; + final JellyfinPlaybackInfo info; + final JellyfinMediaSource source; + /// Full item (series, episode numbers); null if that call failed. + final JellyfinItem? item; + final String url; + final Map headers; + const OwnPlay({ + required this.client, + required this.account, + required this.info, + required this.source, + required this.item, + required this.url, + required this.headers, + }); + + @override + bool get jfAccess => true; +} + +/// Through the sync server's relay (`/j/...`). +final class RelayPlay extends JellyfinPlan { + final String url; + const RelayPlay(this.url); + + @override + bool get jfAccess => false; +} + +/// Neither: no (usable) account here and the server cannot relay. +final class BlockedPlay extends JellyfinPlan { + final String serverId; + /// Known only when this install has (had) an account there. + final String? serverName; + /// Why the own account did not work, if there is one. + final String? problem; + const BlockedPlay({required this.serverId, this.serverName, this.problem}); + + @override + bool? get jfAccess => null; + + String get message { + final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!; + final base = 'This needs a Jellyfin account on $where (or the host can relay)'; + return problem == null ? base : '$base — $problem'; + } +} + +/// Picks how to play [ref]: this install's account first (PlaybackInfo +/// with its own user; a 401 marks the account expired), else the relay +/// when the server offers it ([relay] = `state.relay`, [relayUrl] this +/// member's `/j/...` URL), else [BlockedPlay]. +Future resolveJellyfinRef( + JellyfinAccounts accounts, + JellyfinRef ref, { + required bool relay, + required String? relayUrl, + Duration timeout = const Duration(seconds: 15), +}) async { + await accounts.ensureLoaded(); + final account = accounts.account(ref.serverId); + String? problem; + if (account != null && accounts.statusOf(ref.serverId) == JellyfinAccountStatus.expired) { + problem = 'your sign-in expired, reconnect it in Integrations'; + } else if (account != null) { + final client = accounts.clientFor(ref.serverId)!; + try { + final infoF = client.playbackInfo(ref.itemId, mediaSourceId: ref.mediaSourceId).timeout(timeout); + final itemF = client + .item(ref.itemId) + .timeout(timeout) + .then((i) => i, onError: (Object _) => null); + final info = await infoF; + final item = await itemF; + final source = (ref.mediaSourceId == null + ? null + : info.mediaSources.where((s) => s.id == ref.mediaSourceId).firstOrNull) ?? + info.primarySource; + if (source == null) throw const JellyfinException('no playable source'); + return OwnPlay( + client: client, + account: account, + info: info, + source: source, + item: item, + url: client.shareableStreamUrl(ref.itemId, mediaSourceId: source.id).toString(), + headers: client.authHeaders, + ); + } catch (e) { + debugPrint('jellyfin resolve ${ref.itemId} with own account failed: $e'); + if (e is JellyfinException && e.isUnauthorized) { + accounts.markExpired(ref.serverId); + problem = 'your sign-in expired, reconnect it in Integrations'; + } else if (e is JellyfinException && e.statusCode == 404) { + problem = 'your account cannot see this item'; + } else { + problem = 'could not reach it'; + } + } + } + if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl); + return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem); +} + +// ------------------------------------------------------------ next episode + +/// A neighbouring episode as this viewer can put it up. +class EpisodeLink { + final JellyfinItem item; + final JellyfinMedia pick; + const EpisodeLink(this.item, this.pick); + + /// "S1E7 Battle with the Heroes". + String get label => [?item.episodeLabel, item.name].join(' '); +} + +/// The episodes before and after the one [plan] plays, with its account. +/// Both null for movies, at the ends of a series, or on failure. +Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPlay plan, + {Duration timeout = const Duration(seconds: 15)}) async { + const none = (previous: null, next: null); + final item = plan.item; + if (item == null || item.kind != JellyfinItemKind.episode || item.seriesId == null) return none; + try { + final around = await plan.client.adjacentEpisodes(item).timeout(timeout); + EpisodeLink? link(JellyfinItem? i) => i == null ? null : EpisodeLink(i, pickFor(plan.account, i)); + return (previous: link(around.previous), next: link(around.next)); + } catch (e) { + debugPrint('episode lookup failed: $e'); + return none; + } +} + +/// When the floating "Next episode" pill appears: the last two minutes of +/// [runtime] (all of it for shorter media). One place to switch it to the +/// item's outro / credits segment later. +Duration nextPromptStart(JellyfinItem? item, Duration runtime) { + const lead = Duration(minutes: 2); + return runtime > lead ? runtime - lead : Duration.zero; +} + +/// Who performs the auto-advance swap, so several counting clients do not +/// swap twice: the lowest member id among members that play the item with +/// their own account (`jf[serverId] == true`, not stale). Only what every +/// client sees in `state` decides (who put the item up is not in it: a +/// late joiner never saw that swap), so all clients agree. +String? autoAdvanceSwapper(List members, String serverId) { + final eligible = [ + for (final m in members) + if (!m.stale && m.jf[serverId] == true) m + ]; + if (eligible.isEmpty) return null; + eligible.sort((a, b) => compareMemberIds(a.id, b.id)); + return eligible.first.id; +} + +/// "m2" before "m10": numeric suffix order, then plain text. +int compareMemberIds(String a, String b) { + final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), '')); + final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), '')); + if (na != null && nb != null && na != nb) return na.compareTo(nb); + return a.compareTo(b); +} diff --git a/apps/desktop/lib/src/jellyfin/track_prefs.dart b/apps/desktop/lib/src/jellyfin/track_prefs.dart new file mode 100644 index 0000000..6ef15e3 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/track_prefs.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:sync_relay_core/core.dart'; + +import 'accounts.dart'; + +// Per-viewer audio/subtitle carry-over: the last choice is kept per series +// (and as a global fallback) and matched against the next Jellyfin item's +// tracks with core's `matchTrack`. Local only; nothing goes on the wire. + +const trackPrefsBoxName = 'track_prefs'; +const _keyGlobal = 'global'; + +/// One mpv track as media_kit lists it ([id] is mpv's `aid` / `sid`). +class MpvTrack { + final int id; + final JellyfinStreamType type; + final String? language; + final String? title; + final String? codec; + final int? channels; + final bool isDefault; + const MpvTrack({ + required this.id, + required this.type, + this.language, + this.title, + this.codec, + this.channels, + this.isDefault = false, + }); + + /// As a stream for [matchTrack]; its index is the mpv id. + JellyfinMediaStream get asStream => JellyfinMediaStream( + index: id, + type: type, + language: language, + title: title, + codec: codec, + channels: channels, + isDefault: isDefault); + + /// Menu text: "English (eac3 2ch)", "Track 2". + String get label { + final name = title ?? language ?? 'Track $id'; + final extra = [?codec, if (channels != null) '${channels}ch'].join(' '); + final lang = title != null && language != null ? ' [$language]' : ''; + return extra.isEmpty ? '$name$lang' : '$name$lang ($extra)'; + } +} + +/// A viewer's audio/subtitle choice as traits (indices shift between +/// files). [subtitlesOff] wins over [subtitle]. +class TrackChoice { + final TrackPreference? audio; + final TrackPreference? subtitle; + final bool subtitlesOff; + const TrackChoice({this.audio, this.subtitle, this.subtitlesOff = false}); + + Map toJson() => { + 'audio': ?_prefToJson(audio), + 'subtitle': ?_prefToJson(subtitle), + 'subtitlesOff': subtitlesOff, + }; + + static TrackChoice? fromJson(Object? j) { + if (j is! Map) return null; + return TrackChoice( + audio: _prefFromJson(j['audio'], JellyfinStreamType.audio), + subtitle: _prefFromJson(j['subtitle'], JellyfinStreamType.subtitle), + subtitlesOff: j['subtitlesOff'] == true, + ); + } + + /// The choice behind mpv's [aid] / [sid] (null = none / off): traits from + /// the Jellyfin [source] when this viewer has it (title, channels, + /// forced/HI flags), else from mpv's [tracks]. + factory TrackChoice.of({ + required int? aid, + required int? sid, + JellyfinMediaSource? source, + List tracks = const [], + }) { + TrackPreference? pref(JellyfinStreamType type, int? id) { + if (id == null) return null; + if (source != null) { + final index = source.jellyfinIndexForMpvTrack(type, id); + final s = index == null ? null : source.streamAt(index); + if (s != null) return TrackPreference.fromStream(s); + } + final t = tracks.where((t) => t.type == type && t.id == id).firstOrNull; + return t == null ? null : TrackPreference.fromStream(t.asStream); + } + + return TrackChoice( + audio: pref(JellyfinStreamType.audio, aid), + subtitle: pref(JellyfinStreamType.subtitle, sid), + subtitlesOff: sid == null, + ); + } +} + +Map? _prefToJson(TrackPreference? p) => p == null + ? null + : { + 'language': ?p.language, + 'title': ?p.title, + 'codec': ?p.codec, + 'channels': ?p.channels, + if (p.isForced) 'forced': true, + if (p.isHearingImpaired) 'hi': true, + }; + +TrackPreference? _prefFromJson(Object? j, JellyfinStreamType type) { + if (j is! Map) return null; + String? s(Object? v) => v is String && v.isNotEmpty ? v : null; + return TrackPreference( + type: type, + language: s(j['language']), + title: s(j['title']), + codec: s(j['codec']), + channels: j['channels'] is int ? j['channels'] as int : null, + isForced: j['forced'] == true, + isHearingImpaired: j['hi'] == true, + ); +} + +/// mpv tracks to select for [choice] on a new item. Matches against the +/// Jellyfin [source]'s streams when this viewer has them (mapped to mpv ids +/// with [JellyfinMediaSource.mpvTrackId]), else against mpv's [tracks]. +/// Null fields keep the player's default; [subtitlesOff] turns them off. +({int? aid, int? sid, bool subtitlesOff}) chooseTracks(TrackChoice choice, + {JellyfinMediaSource? source, List tracks = const []}) { + int? pick(TrackPreference? pref) { + if (pref == null) return null; + if (source != null) { + final s = matchTrack(pref, source.mediaStreams); + return s == null ? null : source.mpvTrackId(s.index); + } + return matchTrack(pref, [for (final t in tracks) t.asStream])?.index; + } + + return ( + aid: pick(choice.audio), + sid: choice.subtitlesOff ? null : pick(choice.subtitle), + subtitlesOff: choice.subtitlesOff, + ); +} + +/// The last choice per series and overall, in their own Hive box. +class TrackPrefs { + TrackPrefs({JellyfinStore? store}) : _store = store ?? HiveJellyfinStore(boxName: trackPrefsBoxName); + final JellyfinStore _store; + Future? _open; + + Future _ready() => _open ??= _store.open().catchError((Object e) { + debugPrint('open track prefs failed: $e'); + }); + + /// The series' choice, else the global one. + Future load({String? seriesId}) async { + await _ready(); + try { + Object? read(String key) { + final v = _store.get(key); + return v is String ? jsonDecode(v) : null; + } + + return (seriesId == null ? null : TrackChoice.fromJson(read('series:$seriesId'))) ?? + TrackChoice.fromJson(read(_keyGlobal)); + } catch (e) { + debugPrint('read track prefs failed: $e'); + return null; + } + } + + /// Saves [choice] as the global one and, with [seriesId], the series'. + Future save(TrackChoice choice, {String? seriesId}) async { + await _ready(); + try { + final v = jsonEncode(choice.toJson()); + await _store.put(_keyGlobal, v); + if (seriesId != null) await _store.put('series:$seriesId', v); + } catch (e) { + debugPrint('save track prefs failed: $e'); + } + } +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 76e8fe5..9469ea7 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -5,6 +5,9 @@ import 'package:flutter/widgets.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType; + +import '../jellyfin/track_prefs.dart' show MpvTrack; import '../shaders/shader_assets.dart'; import '../shaders/shader_presets.dart'; @@ -356,16 +359,89 @@ class PlaybackController { } } - Future openUrl(String url) async { + /// Opens [url] paused. [headers] go with every request for it (mpv + /// `http-header-fields`): a Jellyfin token travels there, not in the URL. + Future openUrl(String url, {Map? headers}) async { if (!_ready) return; try { - await player.open(Media(url), play: false); + // An empty map, not null: media_kit caches headers per URI. + await player.open(Media(url, httpHeaders: headers ?? const {}), play: false); } catch (e, st) { - debugPrint('open $url failed: $e\n$st'); + debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st'); rethrow; } } + /// The audio / subtitle tracks of the open file (mpv's track-list). + List get tracks { + final p = _player; + if (p == null) return const []; + MpvTrack? of(dynamic t, JellyfinStreamType type) { + final id = int.tryParse('${t.id}'); + if (id == null) return null; + return MpvTrack( + id: id, + type: type, + language: t.language as String?, + title: t.title as String?, + codec: t.codec as String?, + channels: (t.channelscount ?? t.audiochannels) as int?, + isDefault: t.isDefault == true, + ); + } + + return [ + for (final t in p.state.tracks.audio) ?of(t, JellyfinStreamType.audio), + for (final t in p.state.tracks.subtitle) ?of(t, JellyfinStreamType.subtitle), + ]; + } + + /// Waits (bounded) until the open file's tracks are listed and mpv has + /// selected its audio track, so a report or a carried-over choice sees + /// the real selection. + Future> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async { + final end = DateTime.now().add(timeout); + while (_player != null && DateTime.now().isBefore(end)) { + final t = tracks; + final hasAudio = t.any((x) => x.type == JellyfinStreamType.audio); + if (t.isNotEmpty && (!hasAudio || (await currentTrackIds()).aid != null)) return t; + await Future.delayed(const Duration(milliseconds: 100)); + } + return tracks; + } + + /// mpv's selected audio and subtitle track ids (null = none / off). + Future<({int? aid, int? sid})> currentTrackIds() async { + final p = _player; + if (p == null) return (aid: null, sid: null); + try { + final dynamic platform = p.platform; + final aid = int.tryParse('${await platform?.getProperty('current-tracks/audio/id')}'); + final sid = int.tryParse('${await platform?.getProperty('current-tracks/sub/id')}'); + return (aid: aid, sid: sid); + } catch (_) { + return (aid: null, sid: null); + } + } + + /// Selects tracks by mpv id; a null [aid] keeps audio, [subtitlesOff] + /// turns subtitles off, else a null [sid] keeps them. + Future selectTracks({int? aid, int? sid, bool subtitlesOff = false}) async { + final p = _player; + if (p == null) return; + try { + final dynamic platform = p.platform; + if (aid != null) await platform?.setProperty('aid', '$aid'); + if (subtitlesOff) { + await platform?.setProperty('sid', 'no'); + } else if (sid != null) { + await platform?.setProperty('sid', '$sid'); + } + } catch (e) { + debugPrint('select tracks failed: $e'); + } + } + double get positionSecs { final p = _player; if (p == null) return 0; diff --git a/apps/desktop/lib/src/screens/home/connect_flow.dart b/apps/desktop/lib/src/screens/home/connect_flow.dart index 61f86c3..a2b51a8 100644 --- a/apps/desktop/lib/src/screens/home/connect_flow.dart +++ b/apps/desktop/lib/src/screens/home/connect_flow.dart @@ -1,6 +1,7 @@ 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'; @@ -91,11 +92,11 @@ class ConnectFlow extends ChangeNotifier { } /// [edit] fills the card's fields into a copy of the last form, so the - /// other card's saved values survive. [linkOverride] (host only) is played - /// as the link but never stored in the form: the interim Jellyfin stream - /// URL carries a token. + /// 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, - {String? linkOverride}) async { + {HelloMedia? media}) async { if (busy != null) return; final myAttempt = ++_attempt; final form = _session.lastForm.copy(); @@ -113,15 +114,10 @@ class ConnectFlow extends ChangeNotifier { notices[mode] = null; _notify(); - final ConnectParams params; + ConnectParams params; try { - params = await buildParams( - mode, - linkOverride == null || mode != ConnectMode.host - ? form - : (form.copy() - ..mediaPath = '' - ..linkUrl = linkOverride)); + params = await buildParams(mode, form); + if (media != null && mode == ConnectMode.host) params = params.withMedia(media); } catch (e) { if (myAttempt != _attempt) return; busy = null; diff --git a/apps/desktop/lib/src/screens/home/host_tab.dart b/apps/desktop/lib/src/screens/home/host_tab.dart index 9ff6a20..14321a1 100644 --- a/apps/desktop/lib/src/screens/home/host_tab.dart +++ b/apps/desktop/lib/src/screens/home/host_tab.dart @@ -1,7 +1,9 @@ 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'; @@ -48,20 +50,17 @@ class HostFields { } } -/// INTERIM(jellyfin-protocol): until the protocol carries a Jellyfin item, -/// the host plays it as a link: this install's authorised static stream URL -/// (token in the query, so viewers stream with the host's sign-in and the -/// URL must not be persisted). Throws [FormatException] with a user-facing -/// message when the server is not linked any more. -String interimJellyfinLink(JellyfinAccounts accounts, JellyfinMedia media) { - final client = accounts.clientFor(media.serverId); - if (client == null) { +/// 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 client.streamUrl(media.itemId, mediaSourceId: media.mediaSourceId).toString(); + return mediaForPick(media); } /// Below this width the server column moves under the media picker. @@ -79,10 +78,10 @@ class HostTab extends StatelessWidget { void _host() { if (flow.busy != null) return; final media = fields.media.selection; - String? link; + HelloMedia? jellyfinMedia; if (media is JellyfinMedia) { try { - link = interimJellyfinLink(fields.jellyfin, media); + jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media); } on FormatException catch (e) { flow.showError(ConnectMode.host, CardError('Check the form', e.message)); return; @@ -96,7 +95,7 @@ class HostTab extends StatelessWidget { ..hostSecret = fields.secret.text ..mediaPath = media is FileMedia ? media.path : '' ..linkUrl = media is LinkMedia ? media.url : '', - linkOverride: link, + media: jellyfinMedia, ); } diff --git a/apps/desktop/lib/src/screens/home/media_picker.dart b/apps/desktop/lib/src/screens/home/media_picker.dart index 57302ab..9a47fba 100644 --- a/apps/desktop/lib/src/screens/home/media_picker.dart +++ b/apps/desktop/lib/src/screens/home/media_picker.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../jellyfin/accounts.dart'; +import '../../jellyfin/browse_scope.dart' show BrowseStart; import '../../jellyfin/picker_source.dart'; import '../../widgets/media_input.dart'; import 'picked_media.dart'; @@ -114,8 +115,17 @@ class MediaPicker extends StatefulWidget { /// Jellyfin source only: the browser's "bigger" toggle. final VoidCallback? onToggleExpanded; final bool expanded; + /// Where the Jellyfin browser opens instead of its remembered location. + final BrowseStart? jellyfinStart; + /// False hides the File source (only the host can serve a file). + final bool allowFile; + /// Shown under the Link field instead of the default help. + final String? linkHelp; const MediaPicker({ super.key, + this.jellyfinStart, + this.allowFile = true, + this.linkHelp, required this.controller, this.onChanged, this.onSubmit, @@ -221,6 +231,7 @@ class _MediaPickerState extends State { onOpenIntegrations: widget.onOpenIntegrations, onToggleExpanded: widget.onToggleExpanded, expanded: widget.expanded, + start: widget.jellyfinStart, ), MediaKind.link => _FieldRow( key: const ValueKey('media-link'), @@ -236,7 +247,9 @@ class _MediaPickerState extends State { icon: const Icon(Icons.content_paste, size: 18), label: const Text('Paste'), ), - help: 'A direct http(s) link to a video file; everyone streams it from there.', + help: widget.linkHelp ?? + 'A direct http(s) link to a video file; everyone streams it from there. ' + 'Jellyfin links are shared as the item, without your sign-in.', notice: _c.notice, ), MediaKind.file => _FieldRow( @@ -260,17 +273,17 @@ class _MediaPickerState extends State { child: SegmentedButton( key: const ValueKey('media-source'), showSelectedIcon: false, - segments: const [ - ButtonSegment( + segments: [ + const ButtonSegment( value: MediaKind.jellyfin, icon: Icon(Icons.video_library_outlined), label: Text('Jellyfin')), - ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')), - ButtonSegment( + const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')), + const ButtonSegment( value: MediaKind.file, icon: Icon(Icons.insert_drive_file_outlined), label: Text('File')), - ], + ].where((s) => widget.allowFile || s.value != MediaKind.file).toList(), selected: {source}, onSelectionChanged: (s) => _c.source = s.first, ), diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index b497dd8..fd5a133 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -17,6 +17,7 @@ const sessionShortcuts = [ ('F', 'Toggle fullscreen'), ('Tab', 'Open / close the people & chat panel'), ('Ctrl+R', 'Reload the video'), + ('> or Shift+N', 'Put up the next 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'), @@ -34,6 +35,7 @@ class SettingsPage extends StatelessWidget { const PageTitle('Settings'), const _Section(title: 'Profile', child: ProfileEditor()), const _Section(title: 'Player', child: _HwdecSetting()), + 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()), @@ -290,6 +292,26 @@ class _UpdateSettings extends StatelessWidget { } } +class _AutoAdvanceSetting extends StatelessWidget { + const _AutoAdvanceSetting(); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('auto-advance'), + contentPadding: EdgeInsets.zero, + title: const Text('Play the next episode automatically'), + subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; ' + 'anyone can cancel it on their screen.'), + value: s.autoAdvance.value, + onChanged: s.setAutoAdvance, + ), + ); + } +} + class _ShortcutList extends StatelessWidget { const _ShortcutList(); diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart new file mode 100644 index 0000000..3656933 --- /dev/null +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType; + +import '../../jellyfin/session_media.dart'; +import '../../jellyfin/track_prefs.dart'; +import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; + +// Jellyfin pieces of the session screen. 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 +/// no such episode or this viewer cannot put it up (no account). +class EpisodeSkipButton extends StatelessWidget { + final EpisodeLink? episode; + final bool next; + final VoidCallback onPressed; + final FocusNode? focusNode; + const EpisodeSkipButton( + {super.key, required this.episode, required this.next, required this.onPressed, this.focusNode}); + + @override + Widget build(BuildContext context) { + final e = episode; + if (e == null) return const SizedBox.shrink(); + return Tooltip( + message: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})', + child: IconButton( + key: ValueKey(next ? 'episode-next' : 'episode-previous'), + focusNode: focusNode, + color: Colors.white, + iconSize: 22, + visualDensity: VisualDensity.compact, + icon: Icon(next ? Icons.skip_next_rounded : Icons.skip_previous_rounded), + onPressed: onPressed, + ), + ); + } +} + +/// Floating "Next episode ›" near the end of an episode: faint at rest, +/// lit on hover. Lives outside the auto-hiding controls, so it shows in +/// fullscreen and with the controls hidden. +class NextEpisodePill extends StatefulWidget { + final EpisodeLink episode; + final VoidCallback onPressed; + const NextEpisodePill({super.key, required this.episode, required this.onPressed}); + + @override + State createState() => _NextEpisodePillState(); +} + +class _NextEpisodePillState extends State { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: Tooltip( + message: 'Next: ${widget.episode.label}', + child: AnimatedScale( + duration: const Duration(milliseconds: 150), + scale: _hover ? 1.06 : 1, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _hover ? 1 : 0.45, + child: GestureDetector( + key: const ValueKey('next-episode-pill'), + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _hover ? primary : Colors.white24), + boxShadow: _hover + ? [BoxShadow(color: primary.withValues(alpha: 0.45), blurRadius: 16)] + : const [], + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Next episode', style: TextStyle(color: Colors.white, fontSize: 13)), + SizedBox(width: 4), + Icon(Icons.chevron_right, color: Colors.white, size: 18), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// At the end of an episode: "Next: S1E7 title" with Play now, and while +/// the auto-advance countdown runs, the seconds left and Cancel. +class EndOfEpisodeCard extends StatelessWidget { + final EpisodeLink next; + final AutoAdvance? countdown; + final VoidCallback onPlayNow; + final VoidCallback onCancel; + const EndOfEpisodeCard({ + super.key, + required this.next, + required this.countdown, + required this.onPlayNow, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final c = countdown; + return Card( + key: const ValueKey('end-of-episode'), + color: Colors.black.withValues(alpha: 0.85), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s', + style: t.labelLarge?.copyWith(color: Colors.white70)), + const SizedBox(height: 4), + Text('Next: ${next.label}', + maxLines: 2, overflow: TextOverflow.ellipsis, style: t.titleMedium), + if (c != null) ...[ + const SizedBox(height: 8), + LinearProgressIndicator(value: c.secondsLeft / autoAdvanceSecs), + ], + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (c != null) + TextButton( + key: const ValueKey('auto-advance-cancel'), + onPressed: onCancel, + child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton.icon( + key: const ValueKey('play-next'), + onPressed: onPlayNow, + icon: const Icon(Icons.skip_next_rounded, size: 18), + label: Text(c == null ? 'Play next' : 'Play now'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// This viewer cannot play the Jellyfin media: say why and offer to link +/// an account (never a silent stall). +class JellyfinBlockedCard extends StatelessWidget { + final BlockedPlay blocked; + final VoidCallback onLinkAccount; + final VoidCallback onRetry; + const JellyfinBlockedCard( + {super.key, required this.blocked, required this.onLinkAccount, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Card( + key: const ValueKey('jellyfin-blocked'), + color: Colors.black.withValues(alpha: 0.85), + child: Padding( + padding: const EdgeInsets.all(20), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_outline, color: Colors.orange, size: 32), + const SizedBox(height: 10), + Text(blocked.message, textAlign: TextAlign.center, style: t.titleSmall), + const SizedBox(height: 6), + Text('Link your own account in Integrations, or ask the host to relay it.', + textAlign: TextAlign.center, style: t.bodySmall?.copyWith(color: Colors.grey)), + const SizedBox(height: 14), + Wrap( + spacing: 8, + children: [ + FilledButton( + key: const ValueKey('link-jellyfin-account'), + onPressed: onLinkAccount, + child: const Text('Link a Jellyfin account')), + TextButton(onPressed: onRetry, child: const Text('Try again')), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Small member-list badge: plays the Jellyfin item through the host. +class ViaHostBadge extends StatelessWidget { + const ViaHostBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'No Jellyfin account here: watches through the host', + child: Container( + key: const ValueKey('via-host'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white24), + ), + child: const Text('via host', style: TextStyle(fontSize: 10, color: Colors.white70)), + ), + ); + } +} + +/// A track pick from [TracksControlButton]: audio id, subtitle id, or off. +typedef TrackPick = ({int? aid, int? sid, bool subtitlesOff}); + +/// Audio / subtitle menu for the controls bar. The choice is remembered +/// for the next Jellyfin item (per viewer). +class TracksControlButton extends StatelessWidget { + final FocusNode? focusNode; + final List Function() tracks; + final Future<({int? aid, int? sid})> Function() current; + final void Function(TrackPick pick) onPick; + final ValueChanged? onMenuOpen; + const TracksControlButton({ + super.key, + this.focusNode, + required this.tracks, + required this.current, + required this.onPick, + this.onMenuOpen, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'Audio and subtitles', + child: IconButton( + key: const ValueKey('tracks-button'), + focusNode: focusNode, + color: Colors.white, + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.subtitles_outlined), + onPressed: () => _open(context), + ), + ); + } + + Future _open(BuildContext context) async { + final list = tracks(); + final button = context.findRenderObject() as RenderBox; + final overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + final navigator = Navigator.of(context); + final now = await current(); + if (!navigator.mounted) return; + final audio = list.where((t) => t.type == JellyfinStreamType.audio).toList(); + final subs = list.where((t) => t.type == JellyfinStreamType.subtitle).toList(); + PopupMenuItem header(String text) => PopupMenuItem( + enabled: false, + height: 28, + child: Text(text, style: const TextStyle(fontSize: 12, color: Colors.grey))); + onMenuOpen?.call(true); + final pick = await showMenu( + context: navigator.context, + position: position, + popUpAnimationStyle: AnimationStyle.noAnimation, + constraints: const BoxConstraints(minWidth: 280, maxWidth: 420), + items: [ + header('Audio'), + if (audio.isEmpty) + const PopupMenuItem(enabled: false, height: 32, child: Text('None')), + for (final t in audio) + CheckedPopupMenuItem( + value: (aid: t.id, sid: null, subtitlesOff: false), + checked: now.aid == t.id, + height: 36, + child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + const PopupMenuDivider(), + header('Subtitles'), + CheckedPopupMenuItem( + value: (aid: null, sid: null, subtitlesOff: true), + checked: now.sid == null, + height: 36, + child: const Text('Off'), + ), + for (final t in subs) + CheckedPopupMenuItem( + value: (aid: null, sid: t.id, subtitlesOff: false), + checked: now.sid == t.id, + height: 36, + child: Text(t.label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ); + onMenuOpen?.call(false); + if (pick != null) onPick(pick); + } +} diff --git a/apps/desktop/lib/src/screens/session/swap_panel.dart b/apps/desktop/lib/src/screens/session/swap_panel.dart new file mode 100644 index 0000000..38e695e --- /dev/null +++ b/apps/desktop/lib/src/screens/session/swap_panel.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +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. File is offered to the host only (the file streams from the +/// host's disk). [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; + final VoidCallback? onOpenIntegrations; + const SwapPanel({ + super.key, + required this.controller, + required this.accounts, + required this.isHost, + required this.onSwap, + required this.onClose, + this.jellyfinStart, + this.onOpenIntegrations, + }); + + @override + State createState() => _SwapPanelState(); +} + +class _SwapPanelState extends State { + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + final c = widget.controller; + if (!widget.isHost && c.source == MediaKind.file) c.source = MediaKind.link; + } + + Future _swap() async { + final media = widget.controller.selection; + if (media == null || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + String? error; + try { + error = await widget.onSwap(media); + } catch (e) { + error = '$e'; + } + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + }); + if (error == null) widget.onClose(); + } + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Material( + 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, + allowFile: widget.isHost, + 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(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'), + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart index 932abc1..59e6a10 100644 --- a/apps/desktop/lib/src/screens/session_screen.dart +++ b/apps/desktop/lib/src/screens/session_screen.dart @@ -4,6 +4,8 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +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'; @@ -17,6 +19,10 @@ 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/swap_panel.dart'; class SessionScreen extends StatefulWidget { const SessionScreen({super.key}); @@ -38,7 +44,10 @@ class _SessionScreenState extends State { DateTime? _lastKeyAt; final _chat = TextEditingController(); final _chatFocus = FocusNode(); - final _swapLink = TextEditingController(); + // "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; @@ -54,6 +63,7 @@ class _SessionScreenState extends State { 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(() { @@ -144,14 +154,70 @@ class _SessionScreenState extends State { _stopShaderWatch(); _chat.dispose(); _chatFocus.dispose(); - _swapLink.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) { @@ -177,6 +243,20 @@ class _SessionScreenState extends State { 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; + } if (event.logicalKey == LogicalKeyboardKey.tab) { _lastKeyAt = now; _sheetOpen.value = !_sheetOpen.peek(); @@ -254,12 +334,35 @@ class _SessionScreenState extends State { ), ], bottomButtonBar: [ + 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(), + TracksControlButton( + focusNode: _noFocus, + tracks: () => coord.playback.tracks, + current: coord.playback.currentTrackIds, + onPick: (p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff), + onMenuOpen: (open) => _shaderMenuOpen = open, + ), ShaderControlButton( focusNode: _noFocus, renderMode: coord.playback.renderMode, @@ -320,8 +423,10 @@ class _SessionScreenState extends State { MaterialDesktopVideoControls(state), _healthOverlay(), _waitingOverlay(), + _jellyfinOverlay(), _toastsOverlay(), _sheetOverlay(), + _swapOverlay(), _leaveOverlay(), Positioned.fill( child: SignalBuilder( @@ -329,7 +434,7 @@ class _SessionScreenState extends State { final sheetOpen = _sheetOpen.value; final confirm = _confirmLeave.value; return WindowDragLayer( - enabled: !confirm, + 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), @@ -469,6 +574,94 @@ class _SessionScreenState extends State { ); } + /// Jellyfin: the blocked card, the end-of-episode card (countdown) and + /// the "Next episode" pill. 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; + 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: 110, + child: EndOfEpisodeCard( + next: next, + countdown: countdown, + onPlayNow: coord.playNextEpisode, + onCancel: coord.cancelAutoAdvance, + ), + ) + else if (next != null && pill) + Positioned( + right: 24, + bottom: 96, + child: 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( @@ -526,7 +719,7 @@ class _SessionScreenState extends State { child: _SheetPanel( chat: _chat, chatFocus: _chatFocus, - swapLink: _swapLink, + onChangeVideo: _openSwap, onClose: () => _sheetOpen.value = false, onLeave: () { _sheetOpen.value = false; @@ -697,10 +890,10 @@ class _ToastCardState extends State<_ToastCard> { class _SheetPanel extends StatelessWidget { final TextEditingController chat; final FocusNode chatFocus; - final TextEditingController swapLink; + final VoidCallback onChangeVideo; final VoidCallback onClose; final VoidCallback onLeave; - const _SheetPanel({required this.chat, required this.chatFocus, required this.swapLink, required this.onClose, required this.onLeave}); + const _SheetPanel({required this.chat, required this.chatFocus, required this.onChangeVideo, required this.onClose, required this.onLeave}); @override Widget build(BuildContext context) { @@ -750,7 +943,12 @@ class _SheetPanel extends StatelessWidget { 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), + 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), @@ -790,34 +988,14 @@ class _SheetPanel extends StatelessWidget { ], ), const SizedBox(height: 12), - Text('Share a link', style: sectionStyle), - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: swapLink, - decoration: const InputDecoration(labelText: 'Swap to link URL', border: OutlineInputBorder(), isDense: true), - ), - ), - const SizedBox(width: 8), - ValueListenableBuilder( - valueListenable: swapLink, - builder: (context, value, _) { - final link = value.text.trim(); - return IconButton( - icon: const Icon(Icons.swap_horiz), - onPressed: link.isEmpty - ? null - : () { - coord.swapLink(link); - swapLink.clear(); - }, - ); - }, - ), - ], + 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( @@ -880,10 +1058,14 @@ class _StatusChip extends StatelessWidget { } } +/// 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; - const _MemberRow({required this.member, required this.isSelf}); + final bool viaHost; + const _MemberRow({required this.member, required this.isSelf, this.viaHost = false}); @override Widget build(BuildContext context) { @@ -920,6 +1102,7 @@ class _MemberRow extends StatelessWidget { status.$1, style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)), ), + if (viaHost) ...[const SizedBox(width: 6), const ViaHostBadge()], ], ), ], diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 4161fcd..9dd5a1b 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -4,8 +4,14 @@ import 'package:flutter/widgets.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../jellyfin/accounts.dart'; +import '../jellyfin/progress.dart'; +import '../jellyfin/session_media.dart'; +import '../jellyfin/track_prefs.dart'; import '../playback/playback_controller.dart'; +import '../screens/home/picked_media.dart'; import '../sfx/sfx.dart'; +import '../state/settings.dart'; import '../sync/state_diff.dart'; import '../sync/sync_client.dart'; import '../sync/user_seek.dart'; @@ -21,19 +27,7 @@ class ConnectCancelled implements Exception { /// Display name for a link: last path segment, decoded, never the query /// (Jellyfin-style links carry api_key there and names are broadcast). -String linkDisplayName(String url) { - final uri = Uri.tryParse(url.trim()); - if (uri == null) return 'link'; - final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList(); - if (segs.isNotEmpty) { - try { - return Uri.decodeComponent(segs.last); - } catch (_) { - return segs.last; - } - } - return uri.host.isNotEmpty ? uri.host : 'link'; -} +String linkDisplayName(String url) => linkName(url); class ConnectParams { final String host; @@ -45,6 +39,9 @@ class ConnectParams { final String linkUrl; /// Sent in hello by the hosting app's own client (see AppConfig.hostKey). final String hostKey; + /// Put up with a `swap` right after joining (a Jellyfin ref or a link); + /// wins over [linkUrl]. + final HelloMedia? media; const ConnectParams({ required this.host, required this.port, @@ -54,9 +51,37 @@ class ConnectParams { this.mediaPath = '', this.linkUrl = '', this.hostKey = '', + this.media, }); + + ConnectParams withMedia(HelloMedia? media) => ConnectParams( + host: host, + port: port, + secret: secret, + name: name, + avatar: avatar, + mediaPath: mediaPath, + linkUrl: media == null ? linkUrl : '', + hostKey: hostKey, + media: media, + ); } +/// The end-of-episode countdown (auto-advance). Every client with the +/// setting on shows it; only [swapper] puts the next episode up. +class AutoAdvance { + final int mediaId; + final int secondsLeft; + final EpisodeLink next; + /// This client performs the swap (see [autoAdvanceSwapper]). + final bool swapper; + const AutoAdvance( + {required this.mediaId, required this.secondsLeft, required this.next, required this.swapper}); +} + +/// Seconds of the end-of-episode countdown. +const autoAdvanceSecs = 10; + class ToastItem { final int id; final String text; @@ -76,6 +101,13 @@ String? stateMediaUrl(StateBroadcast state) { class SessionCoordinator { final SfxService sfx; + /// Linked Jellyfin servers: how this viewer plays Jellyfin refs. + final JellyfinAccounts jellyfin; + /// Audio/subtitle carry-over between Jellyfin items. + final TrackPrefs trackPrefs; + /// Set by the hosting app: makes a local file the hosted one (File swap); + /// null when this app is not the host. + Future<({String name, int size})?> Function(String path)? hostFile; final SyncClient sync = SyncClient(); final PlaybackController playback = PlaybackController(); final StateDiff diff = StateDiff(); @@ -89,6 +121,21 @@ class SessionCoordinator { final kickReason = signal(null); final starving = signal(null); + /// The current media's Jellyfin ref (null for links and files). + final currentRef = signal(null); + /// How this viewer plays [currentRef]; null while resolving. + final jellyfinPlan = signal(null); + /// Set when this viewer cannot play the Jellyfin media: no account and + /// no relay. The session shows it with a way to link an account. + final jellyfinBlocked = signal(null); + /// Neighbouring episodes this viewer can put up (own account only). + final previousEpisode = signal(null); + final nextEpisode = signal(null); + /// The floating "Next episode" pill near the end of an episode. + final nextPromptVisible = signal(false); + /// End-of-episode countdown; null when none runs here. + final autoAdvance = signal(null); + late final Computed selfMember = computed(() { final r = serverState.value; if (r == null || sync.memberId.isEmpty) return null; @@ -139,8 +186,25 @@ class SessionCoordinator { int _toastId = 0; ConnectParams? _params; bool _everConnected = false; + Map? _openHeaders; - SessionCoordinator({this.sfx = const NoopSfxService()}); + // Jellyfin: the resolved ref, the mediaId it belongs to, a generation + // that stale resolutions check, and the progress reporter. + JellyfinRef? _jfRef; + int _jfMediaId = -1; + int _jfGen = 0; + JellyfinProgressReporter? _reporter; + PlayerSnapshot? _lastSnap; + bool _jfToastShown = false; + Timer? _advanceTimer; + int _advanceDoneFor = -1; + String _blockedKey = ''; + bool _jellyfinListening = false; + + SessionCoordinator( + {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs}) + : jellyfin = jellyfin ?? JellyfinAccounts.instance, + trackPrefs = trackPrefs ?? TrackPrefs(); /// Player lifetime = one session: created here on connect, disposed by /// [leave]. Never reused across sessions (a GPU-side video freeze sticks @@ -175,9 +239,10 @@ class SessionCoordinator { try { await playback.recreate(); final url = _openUrl; + final headers = _openHeaders; _openUrl = null; _clearPendingSeek(); - if (url != null) await _openMedia(url); + if (url != null) await _openMedia(url, headers: headers); } catch (e, st) { debugPrint('video reload failed: $e\n$st'); health.value = @@ -221,6 +286,12 @@ class SessionCoordinator { _seekGate.reset(); _clearPendingSeek(); _lastEventId = -1; + _resetJellyfin(); + _jfToastShown = false; + if (!_jellyfinListening) { + _jellyfinListening = true; + jellyfin.addListener(_onAccountsChanged); + } markInput(); _subscribe(); feedLog.value = []; @@ -252,13 +323,10 @@ class SessionCoordinator { health.value = const HealthStatus( HealthSeverity.info, 'connected — waiting for host media…'); } - if (params.linkUrl.isNotEmpty) { - sync.sendSwap(HelloMedia( - name: linkDisplayName(params.linkUrl), - size: 0, - duration: 0, - url: params.linkUrl)); - } + final media = params.media ?? + (params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl)); + if (epoch != _epoch) throw const ConnectCancelled(); + if (media != null) sync.sendSwap(media); _startTicker(); } catch (e) { if (epoch != _epoch) throw const ConnectCancelled(); @@ -286,19 +354,23 @@ class SessionCoordinator { }); } - Future _openMedia(String url) async { - if (url == _openUrl) return; + /// Opens [url] (with [headers] on every request); false on failure. + Future _openMedia(String url, {Map? headers}) async { + if (url == _openUrl) return true; _openUrl = url; + _openHeaders = headers; diff.reset(); _opening = true; try { - await playback.openUrl(url); + await playback.openUrl(url, headers: headers); health.value = const HealthStatus(HealthSeverity.ok, 'connected'); + return true; } catch (e) { debugPrint('open media failed: $e'); health.value = HealthStatus(HealthSeverity.error, 'cannot open media: $e'); + return false; } finally { _opening = false; } @@ -308,28 +380,73 @@ class SessionCoordinator { return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}'; } - Future _onState(StateBroadcast r) async { + StateBroadcast? _queuedState; + bool _applying = false; + + /// States are applied one at a time, newest first: two applies running + /// interleaved could land an older pause after a newer play (seen with + /// three viewers readying at once). A state that arrives meanwhile + /// replaces any still waiting. + void _onState(StateBroadcast r) { _lastStateAt = DateTime.now().toUtc(); serverState.value = r; + _queuedState = r; + if (_applying) return; + _applying = true; + () async { + try { + while (_queuedState != null) { + final next = _queuedState!; + _queuedState = null; + try { + await _applyState(next); + } catch (e, st) { + debugPrint('apply state failed: $e\n$st'); + } + } + } finally { + _applying = false; + } + }(); + } + + Future _applyState(StateBroadcast r) async { _trackSelfReady(r); _seekGate.observe(r, sync.memberId); final newEvent = r.eventId != _lastEventId; _lastEventId = r.eventId; if (r.mediaId != _mediaId) { _mediaId = r.mediaId; - var url = stateMediaUrl(r); - if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { - final name = (r.media?['name'] as String?) ?? ''; - if (name.isNotEmpty) url = _fileUrlFor(name); - } - if (url != null && url.isNotEmpty) { - await _openMedia(url); - } else { - _openUrl = null; + final ref = refOfStateMedia(r.media); + // Back after a dropped connection on the same item played with our + // own account: nothing to resolve or reopen. + final same = ref != null && + r.mediaId == _jfMediaId && + sameJellyfinItem(ref, _jfRef) && + jellyfinPlan.peek() is OwnPlay; + if (!same) { + _seekGate.reset(); + _clearPendingSeek(); + _leaveJellyfinItem(); + if (ref != null) { + await _playJellyfin(ref, r); + } else { + var url = stateMediaUrl(r); + if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { + final name = (r.media?['name'] as String?) ?? ''; + if (name.isNotEmpty) url = _fileUrlFor(name); + } + if (url != null && url.isNotEmpty) { + await _openMedia(url); + } else { + _openUrl = null; + } + } } _seekGate.reset(); _clearPendingSeek(); } + _jellyfinTransport(r); if (!_playerReady || _openUrl == null) { _wasPlayAllowed = false; _updateStarving(r, 0); @@ -475,6 +592,7 @@ class SessionCoordinator { // Stop, not pause: a paused player keeps filling its cache from the // host's media URL. _openUrl = null; + await _stopJellyfin(); try { await playback.stop(); } catch (_) {} @@ -504,8 +622,10 @@ class SessionCoordinator { eventId: r.eventId, duration: known ? playback.durationSecs : 0, mediaId: known ? r.mediaId : 0, + jf: _jfAccessFor(r), ); _checkSeekLanded(r); + unawaited(_tickJellyfin(r)); } if (!_manualClose && kickReason.peek() == null && @@ -661,10 +781,362 @@ class SessionCoordinator { sync.sendKick(memberId, reason: reason); } + /// A pasted link: a recognised Jellyfin item link goes up as a ref + /// (see [mediaForLink]), anything else as the link. Future swapLink(String url) async { markInput(); - sync.sendSwap(HelloMedia( - name: linkDisplayName(url), size: 0, duration: 0, url: url)); + sync.sendSwap(await mediaForLink(jellyfin, url)); + } + + /// A Jellyfin pick (browser, next / previous episode) goes up as a ref. + void swapJellyfin(JellyfinMedia media) { + markInput(); + sync.sendSwap(mediaForPick(media)); + } + + /// The host's File swap: the server takes the file, then the swap puts + /// it up. Returns an error text, or null when sent. + Future swapFile(String path) async { + markInput(); + final host = hostFile; + if (host == null) return 'only the host can share a file'; + final file = await host(path); + if (file == null) return 'file not found — check the path'; + sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0)); + return null; + } + + /// Any picker selection. + Future swapPicked(PickedMedia media) async { + switch (media) { + case LinkMedia(:final url): + await swapLink(url); + case JellyfinMedia(): + swapJellyfin(media); + case FileMedia(:final path): + return swapFile(path); + } + return null; + } + + void playNextEpisode() { + final n = nextEpisode.peek(); + if (n != null) swapJellyfin(n.pick); + } + + void playPreviousEpisode() { + final p = previousEpisode.peek(); + if (p != null) swapJellyfin(p.pick); + } + + /// Hides the countdown here; this client will not auto-advance this + /// item (if it is the swapper, nobody does). + void cancelAutoAdvance() { + _advanceTimer?.cancel(); + _advanceTimer = null; + final a = autoAdvance.peek(); + if (a != null) _advanceDoneFor = a.mediaId; + autoAdvance.value = null; + } + + // ------------------------------------------------------------- jellyfin + + void _pushToast(String text) { + _toastId++; + toasts.value = [ + ...toasts.peek(), + ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc()) + ]; + } + + void _resetJellyfin() { + _jfGen++; + _jfRef = null; + _jfMediaId = -1; + _reporter = null; + _lastSnap = null; + _openHeaders = null; + _blockedKey = ''; + currentRef.value = null; + jellyfinPlan.value = null; + jellyfinBlocked.value = null; + previousEpisode.value = null; + nextEpisode.value = null; + nextPromptVisible.value = false; + _advanceTimer?.cancel(); + _advanceTimer = null; + _advanceDoneFor = -1; + autoAdvance.value = null; + } + + /// The current item is being replaced: Stopped goes out (not awaited: + /// reports are sent in order), per-item state clears. + void _leaveJellyfinItem() { + final reporter = _reporter; + final snap = _snapNow(); + _resetJellyfin(); + if (reporter != null && snap != null) unawaited(reporter.stop(snap)); + } + + /// Leave / kick / window close: Stopped, bounded so leaving never hangs. + Future _stopJellyfin() async { + final reporter = _reporter; + final snap = _snapNow(); + _resetJellyfin(); + if (reporter == null || snap == null) return; + try { + await reporter.stop(snap).timeout(const Duration(milliseconds: 1500)); + } catch (_) {} + } + + /// The last sampled tracks with the position as of now. + PlayerSnapshot? _snapNow() { + if (!_playerReady) return _lastSnap; + final last = _lastSnap; + return PlayerSnapshot( + position: playback.positionSecs, + paused: !playback.player.state.playing, + aid: last?.aid, + sid: last?.sid, + ); + } + + Future _snapshot() async { + if (!_playerReady) return null; + final ids = await playback.currentTrackIds(); + if (!_playerReady) return null; + return _lastSnap = PlayerSnapshot( + position: playback.positionSecs, + paused: !playback.player.state.playing, + aid: ids.aid, + sid: ids.sid, + ); + } + + String? _relayUrlFor(JellyfinRef ref) { + if (sync.token.isEmpty) return null; + final welcome = sync.relayUrl; + if (welcome != null && + welcome.contains('/${sync.token}/') && + welcome.endsWith('/${Uri.encodeComponent(ref.itemId)}')) { + return welcome; + } + return relayUrlFor(_wsHost, _wsPort, sync.token, ref.itemId); + } + + Map? _jfAccessFor(StateBroadcast r) { + final ref = _jfRef; + final access = jellyfinPlan.peek()?.jfAccess; + if (ref == null || access == null || r.mediaId != _jfMediaId) return null; + return {ref.serverId: access}; + } + + /// Resolves [ref] for this viewer and opens it: own account (direct + /// play, progress reports, neighbouring episodes), the relay, or the + /// blocked state. Then carries the audio/subtitle choice over. + Future _playJellyfin(JellyfinRef ref, StateBroadcast r) async { + final gen = ++_jfGen; + _jfRef = ref; + _jfMediaId = r.mediaId; + currentRef.value = ref; + // Until it resolves nothing is open: no syncing the previous media. + final prevUrl = _openUrl; + _openUrl = null; + _opening = true; + if (_playerReady) { + try { + await playback.player.pause(); + } catch (_) {} + } + final JellyfinPlan plan; + try { + plan = await resolveJellyfinRef(jellyfin, ref, relay: r.relay, relayUrl: _relayUrlFor(ref)); + } finally { + if (gen == _jfGen) _opening = false; + } + if (gen != _jfGen) return; + jellyfinPlan.value = plan; + switch (plan) { + case BlockedPlay(): + jellyfinBlocked.value = plan; + _blockedKey = _accountKey(ref.serverId); + try { + await playback.stop(); + } catch (_) {} + return; + case RelayPlay(:final url): + jellyfinBlocked.value = null; + // Same relay URL (resumed member): the player still has it. + if (url == prevUrl) { + _openUrl = url; + return; + } + if (!await _openMedia(url) || gen != _jfGen) return; + case OwnPlay(:final url, :final headers): + jellyfinBlocked.value = null; + if (!await _openMedia(url, headers: headers) || gen != _jfGen) return; + final reporter = JellyfinProgressReporter( + client: plan.client, + itemId: ref.itemId, + source: plan.source, + playSessionId: plan.info.playSessionId, + onUnauthorized: () => jellyfin.markExpired(ref.serverId), + onError: (e) { + if (_jfToastShown) return; + _jfToastShown = true; + _pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}'); + }, + ); + unawaited(_findEpisodes(plan, gen)); + // Playing carries the tracks: wait (bounded) until the file is in. + await playback.waitForTracks(); + if (gen != _jfGen) return; + final snap = await _snapshot(); + if (gen != _jfGen || snap == null) return; + // Installed once started: the per-state restart (after the end) + // must not send the first Playing without the tracks. + _reporter = reporter..start(snap); + } + await _applyTrackChoice(plan, gen); + } + + Future _findEpisodes(OwnPlay plan, int gen) async { + final around = await findAdjacentEpisodes(plan); + if (gen != _jfGen) return; + previousEpisode.value = around.previous; + nextEpisode.value = around.next; + final r = serverState.peek(); + if (r != null) _jellyfinTransport(r); + } + + Future _applyTrackChoice(JellyfinPlan plan, int gen) async { + final own = plan is OwnPlay ? plan : null; + final choice = await trackPrefs.load(seriesId: own?.item?.seriesId); + if (choice == null || gen != _jfGen || !_playerReady) return; + final tracks = await playback.waitForTracks(); + if (gen != _jfGen) return; + final pick = chooseTracks(choice, source: own?.source, tracks: tracks); + await playback.selectTracks(aid: pick.aid, sid: pick.sid, subtitlesOff: pick.subtitlesOff); + } + + /// A track picked in the player: applied, remembered for the next + /// Jellyfin item (per series and overall), reported. + Future selectTrack({int? aid, int? sid, bool subtitlesOff = false}) async { + markInput(); + await playback.selectTracks(aid: aid, sid: sid, subtitlesOff: subtitlesOff); + final ids = await playback.currentTrackIds(); + final plan = jellyfinPlan.peek(); + final own = plan is OwnPlay ? plan : null; + await trackPrefs.save( + TrackChoice.of(aid: ids.aid, sid: ids.sid, source: own?.source, tracks: playback.tracks), + seriesId: own?.item?.seriesId); + final snap = await _snapshot(); + if (snap != null) _reporter?.update(snap, force: true); + } + + Future _tickJellyfin(StateBroadcast r) async { + final reporter = _reporter; + if (_jfRef == null || r.mediaId != _jfMediaId) return; + final snap = await _snapshot(); + if (snap == null) return; + if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') { + reporter.update(snap); + } + final next = nextEpisode.peek(); + final duration = playback.durationSecs; + var show = false; + if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') { + final item = (jellyfinPlan.peek() as OwnPlay?)?.item; + final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round())); + show = snap.position * 1000 >= at.inMilliseconds; + } + nextPromptVisible.value = show; + } + + /// Per state: played to the end -> Stopped (Jellyfin marks it watched) + /// and the auto-advance countdown; seeked back from the end -> Playing + /// again. + void _jellyfinTransport(StateBroadcast r) { + final ref = _jfRef; + if (ref == null || r.mediaId != _jfMediaId) return; + final reporter = _reporter; + final finished = r.transport == 'finished'; + if (reporter != null && _openUrl != null && !_opening) { + final snap = _snapNow(); + if (finished && reporter.active && snap != null) { + unawaited(reporter.stop(PlayerSnapshot( + position: r.position, paused: true, aid: snap.aid, sid: snap.sid))); + } else if (!finished && !reporter.active && snap != null) { + reporter.start(snap); + } + } + if (!finished) { + if (autoAdvance.peek() != null) cancelAutoAdvance(); + _advanceDoneFor = -1; + return; + } + nextPromptVisible.value = false; + final next = nextEpisode.peek(); + if (next == null || + !AppSettings.instance.autoAdvance.peek() || + _advanceDoneFor == r.mediaId || + autoAdvance.peek()?.mediaId == r.mediaId) { + return; + } + autoAdvance.value = AutoAdvance( + mediaId: r.mediaId, secondsLeft: autoAdvanceSecs, next: next, swapper: _isSwapper(r, ref)); + _advanceTimer?.cancel(); + _advanceTimer = Timer.periodic(const Duration(seconds: 1), (_) => _advanceTick()); + } + + bool _isSwapper(StateBroadcast r, JellyfinRef ref) => + autoAdvanceSwapper(r.members, ref.serverId) == sync.memberId; + + void _advanceTick() { + final a = autoAdvance.peek(); + final r = serverState.peek(); + final ref = _jfRef; + if (a == null || r == null || ref == null || r.mediaId != a.mediaId || r.transport != 'finished') { + cancelAutoAdvance(); + return; + } + final left = a.secondsLeft - 1; + if (left > 0) { + autoAdvance.value = AutoAdvance( + mediaId: a.mediaId, secondsLeft: left, next: a.next, swapper: _isSwapper(r, ref)); + return; + } + // Decided on the latest state: one swapper among the clients counting. + final swap = _isSwapper(r, ref); + cancelAutoAdvance(); + if (swap) swapJellyfin(a.next.pick); + } + + String _accountKey(String serverId) => + '${jellyfin.account(serverId)?.token}|${jellyfin.statusOf(serverId).name}'; + + /// A blocked viewer links (or reconnects) the account: try again. + void _onAccountsChanged() { + final blocked = jellyfinBlocked.peek(); + if (blocked == null) return; + final key = _accountKey(blocked.serverId); + if (key == _blockedKey) return; + _blockedKey = key; + final status = jellyfin.statusOf(blocked.serverId); + if (jellyfin.account(blocked.serverId) == null || + status == JellyfinAccountStatus.expired || + status == JellyfinAccountStatus.checking) { + return; + } + retryJellyfin(); + } + + /// "Try again" on the blocked card (and after linking an account). + void retryJellyfin() { + final r = serverState.peek(); + final ref = refOfStateMedia(r?.media); + if (r == null || ref == null) return; + unawaited(_playJellyfin(ref, r)); } void _scheduleReconnect({bool manual = false}) { @@ -790,6 +1262,7 @@ class SessionCoordinator { _seekTimer = null; _seekGate.reset(); reconnecting.value = false; + await _stopJellyfin(); _playerReady = false; try { await playback.dispose(); @@ -806,6 +1279,10 @@ class SessionCoordinator { Future dispose() async { await leave(silent: true); + if (_jellyfinListening) { + _jellyfinListening = false; + jellyfin.removeListener(_onAccountsChanged); + } await _stateSub?.cancel(); await _feedSub?.cancel(); await _kickSub?.cancel(); diff --git a/apps/desktop/lib/src/state/server_isolate.dart b/apps/desktop/lib/src/state/server_isolate.dart index 7740e2b..224d07d 100644 --- a/apps/desktop/lib/src/state/server_isolate.dart +++ b/apps/desktop/lib/src/state/server_isolate.dart @@ -131,6 +131,27 @@ class ServerIsolate { if (!_stopping) crash?.call(_lastError ?? 'the server stopped unexpectedly'); } + /// Makes [path] the hosted file (the in-session File swap). Returns its + /// name and size for the `swap` that puts it up, or null when the file + /// is missing or the server is gone. + Future<({String name, int size})?> setFile(String path, + {Duration timeout = const Duration(seconds: 5)}) async { + if (_exited.isCompleted) return null; + final reply = ReceivePort('server file'); + try { + _stop.send({'t': 'file', 'path': path, 'reply': reply.sendPort}); + final msg = await reply.first.timeout(timeout); + if (msg is Map && msg['ok'] == true) { + return (name: msg['name'] as String, size: msg['size'] as int); + } + return null; + } catch (_) { + return null; + } finally { + reply.close(); + } + } + /// Asks the server to close its sockets and exit; kills the isolate if it /// has not exited within [timeout]. Future stop({Duration timeout = const Duration(seconds: 3)}) async { diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index c15319d..bd05f8c 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -6,6 +6,8 @@ import 'package:flutter/widgets.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../jellyfin/accounts.dart'; +import '../jellyfin/session_media.dart'; import '../sfx/sfx.dart'; import '../session/health.dart'; import '../session/session_coordinator.dart'; @@ -154,6 +156,7 @@ class SessionState { final SfxService sfx = const NoopSfxService(); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); final lastForm = ConnectForm(); + JellyfinAccounts get jellyfin => JellyfinAccounts.instance; /// Set when the hosted server died mid-session; Home shows it on the /// Host card and clears it. final serverCrash = signal(null); @@ -232,6 +235,14 @@ class SessionState { if (op != _op) throw const ConnectCancelled(); final hostKey = newHostKey(); _setupCrash = null; + // A pasted Jellyfin link goes up as a ref like a browser pick. + final media = params.media ?? + (params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl)); + if (op != _op) throw const ConnectCancelled(); + // Guests without an account watch through the server, with the + // host's own sign-in (the hosted item's server, else the default). + await jellyfin.ensureLoaded(validate: false); + final relay = relayCredentialFor(jellyfin, serverId: media?.jellyfin?.serverId); late final ServerIsolate server; try { server = await ServerIsolate.start( @@ -241,6 +252,7 @@ class SessionState { name: 'server', mediaPath: params.mediaPath, hostKey: hostKey, + jellyfin: relay, ), onLog: (line) => debugPrint('[server] $line'), onCrash: (cause) => _onServerCrash(server, cause), @@ -256,6 +268,8 @@ class SessionState { throw const ConnectCancelled(); } _server = server; + coordinator.hostFile = (path) async => + identical(_server, server) ? server.setFile(path) : null; status.value = HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}'); try { @@ -265,8 +279,8 @@ class SessionState { secret: params.secret, name: params.name, avatar: params.avatar, - linkUrl: params.linkUrl, hostKey: hostKey, + media: media, )); if (op != _op) throw const ConnectCancelled(); } catch (e) { @@ -353,6 +367,7 @@ class SessionState { } Future stopServer() async { + coordinator.hostFile = null; _stopKickWatch?.call(); _stopKickWatch = null; final server = _server; diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index ffb8e64..6c181d3 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -16,6 +16,7 @@ const _keyAutoCheckUpdates = 'autoCheckUpdates'; const _keyShaderPreset = 'shaderPreset'; const _keyShaderQuality = 'shaderQuality'; const _keyCustomShaders = 'customShaders'; +const _keyAutoAdvance = 'autoAdvance'; enum HwdecMode { auto, off, custom } @@ -25,6 +26,8 @@ class AppSettings { final hwdecMode = signal(HwdecMode.auto); final hwdecCustom = signal(''); final autoCheckUpdates = signal(true); + /// Jellyfin episodes: at the end, count down 10 s and put up the next. + final autoAdvance = signal(true); /// Video shaders: one choice, used on join and changed both here and /// in the session (Ctrl+0..6, the shader button). @@ -59,6 +62,8 @@ class AppSettings { if (custom is String) hwdecCustom.value = custom; final auto = box.get(_keyAutoCheckUpdates); if (auto is bool) autoCheckUpdates.value = auto; + final advance = box.get(_keyAutoAdvance); + if (advance is bool) autoAdvance.value = advance; final preset = box.get(_keyShaderPreset); shaderPreset.value = ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? @@ -91,6 +96,11 @@ class AppSettings { _put(_keyAutoCheckUpdates, on); } + void setAutoAdvance(bool on) { + autoAdvance.value = on; + _put(_keyAutoAdvance, on); + } + void setShaderPreset(ShaderPreset preset) { shaderPreset.value = preset; _put(_keyShaderPreset, preset.name); diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index dfcceaa..c8df516 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -36,6 +36,9 @@ class SyncClient { String token = ''; String memberId = ''; String? mediaUrl; + /// This member's `/j/...` URL from `welcome` (Jellyfin media the server + /// relays); private like [token]. + String? relayUrl; StateBroadcast? lastState; Completer? _pendingWelcome; @@ -89,6 +92,7 @@ class SyncClient { token = welcome.token; memberId = welcome.memberId; mediaUrl = welcome.mediaUrl; + relayUrl = welcome.relayUrl; if (welcome.state != null) { try { lastState = StateBroadcast.fromJson(welcome.state!); @@ -214,6 +218,7 @@ class SyncClient { required int eventId, double duration = 0, int mediaId = 0, + Map? jf, }) { final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; _send( @@ -223,6 +228,7 @@ class SyncClient { eventId: eventId, duration: duration.isFinite && duration > 0 ? duration : 0, mediaId: mediaId, + jf: jf, ).toJson(), ); } @@ -254,6 +260,7 @@ class SyncClient { token = ''; memberId = ''; mediaUrl = null; + relayUrl = null; lastState = null; } diff --git a/apps/desktop/test/jellyfin_accounts_test.dart b/apps/desktop/test/jellyfin_accounts_test.dart index 44b8f2a..280c5e5 100644 --- a/apps/desktop/test/jellyfin_accounts_test.dart +++ b/apps/desktop/test/jellyfin_accounts_test.dart @@ -7,7 +7,7 @@ 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 interimJellyfinLink, mediaSummary; +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. @@ -342,22 +342,6 @@ void main() { expect(m.subtitle, 'Black Summoner · S1E6'); expect(m.runtime, isNotNull); }); - - test('INTERIM: the host plays a Jellyfin pick as its authorised stream link', () async { - final (accounts, _) = await linkedAccounts(); - const m = JellyfinMedia( - serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 't'); - final url = Uri.parse(interimJellyfinLink(accounts, m)); - expect(url.path, '/Videos/abc/stream'); - expect(url.queryParameters['static'], 'true'); - expect(url.queryParameters['api_key'], 'TEST_ACCESS_TOKEN'); - expect( - () => interimJellyfinLink( - accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')), - throwsFormatException); - accounts.markExpired(fixtureServerId); - expect(() => interimJellyfinLink(accounts, m), throwsFormatException); - }); }); group('format', () { diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart new file mode 100644 index 0000000..1eb34c8 --- /dev/null +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -0,0 +1,448 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:watch_together/src/jellyfin/accounts.dart'; +import 'package:watch_together/src/jellyfin/session_media.dart'; +import 'package:watch_together/src/jellyfin/track_prefs.dart'; +import 'package:watch_together/src/session/session_coordinator.dart'; + +// Two account holders and a guest watch Jellyfin episodes together over a +// real sync server, real mpv and a local fake Jellyfin (never a real one): +// account holders direct-play with their own token and report progress, +// the guest plays through the server's /j relay with the host credential. + +final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; +const _sid = 'f00dfeedf00dfeedf00dfeedf00dfeed'; +const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e'; +const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1'; +const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2'; + +/// 20 s, h264 + two audio tracks (Japanese first, English second). +Future _media() async { + final path = '$_tmp/jellyfin_e2e_20s.mkv'; + if (await File(path).exists()) return path; + final r = await Process.run('ffmpeg', [ + '-y', '-v', 'error', + '-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000', + '-map', '0:v', '-map', '1:a', '-map', '2:a', + '-t', '20', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', + '-metadata:s:a:0', 'language=jpn', '-metadata:s:a:0', 'title=Japanese', + '-metadata:s:a:1', 'language=eng', '-metadata:s:a:1', 'title=English', + path, + ]); + if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}'); + return path; +} + +class _Call { + final String method; + final String path; + final String auth; + final Map? body; + _Call(this.method, this.path, this.auth, this.body); +} + +/// Just enough Jellyfin: public info, users, PlaybackInfo, items, episodes, +/// the static stream with ranges, and the three Sessions reports. Every +/// request is recorded with its Authorization header. +class _FakeJellyfin { + final String file; + final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'}; + final calls = <_Call>[]; + late HttpServer _server; + _FakeJellyfin(this.file); + + String get base => 'http://127.0.0.1:${_server.port}'; + + Future start() async { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _server.listen(_handle); + } + + Future stop() => _server.close(force: true); + + static Map authFields(String header) => { + for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(header)) + m.group(1)!: Uri.decodeComponent(m.group(2)!), + }; + + Map _item(String id) => { + 'Id': id, + 'Name': id == _ep1 ? 'Pilot' : 'Second', + 'Type': 'Episode', + 'ServerId': _sid, + 'SeriesId': _series, + 'SeriesName': 'Fake Show', + 'SeasonId': 'seas1', + 'ParentIndexNumber': 1, + 'IndexNumber': id == _ep1 ? 1 : 2, + 'RunTimeTicks': 20 * jellyfinTicksPerSecond, + }; + + Future _handle(HttpRequest req) async { + final auth = req.headers.value('authorization') ?? ''; + final bodyText = await utf8.decoder.bind(req).join(); + Map? body; + try { + body = bodyText.isEmpty ? null : jsonDecode(bodyText) as Map; + } catch (_) {} + calls.add(_Call(req.method, req.uri.path, auth, body)); + final res = req.response; + Future json(Object v) async { + res.headers.contentType = ContentType.json; + res.write(jsonEncode(v)); + await res.close(); + } + + final path = req.uri.path; + if (path == '/System/Info/Public') { + return json({'Id': _sid, 'ServerName': 'Fake Jellyfin', 'Version': '10.11.11'}); + } + final token = authFields(auth)['Token'] ?? req.uri.queryParameters['api_key']; + final user = tokens[token]; + if (user == null) { + res.statusCode = 401; + return res.close(); + } + if (path == '/Users/Me') return json({'Id': user, 'Name': user, 'ServerId': _sid}); + final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path); + if (m != null && m.group(2) != null) { + return json({ + 'PlaySessionId': 'ps-$user-${m.group(1)}', + 'MediaSources': [ + { + 'Id': m.group(1), + 'SupportsDirectPlay': true, + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264'}, + {'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Title': 'Japanese', 'Codec': 'aac'}, + {'Index': 2, 'Type': 'Audio', 'Language': 'eng', 'Title': 'English', 'Codec': 'aac'}, + ], + } + ], + }); + } + if (m != null) return json(_item(m.group(1)!)); + if (path == '/Shows/$_series/Episodes') { + return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2}); + } + if (path.startsWith('/Sessions/Playing')) { + res.statusCode = 204; + return res.close(); + } + if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req); + res.statusCode = 404; + await res.close(); + } + + Future _stream(HttpRequest req) async { + final f = File(file); + final size = await f.length(); + final res = req.response; + res.headers.set('Accept-Ranges', 'bytes'); + res.headers.contentType = ContentType('video', 'x-matroska'); + var start = 0, end = size - 1; + final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? ''); + if (range != null) { + start = int.tryParse(range.group(1)!) ?? 0; + end = int.tryParse(range.group(2)!) ?? size - 1; + if (end >= size) end = size - 1; + res.statusCode = 206; + res.headers.set('Content-Range', 'bytes $start-$end/$size'); + } + res.contentLength = end - start + 1; + try { + await res.addStream(f.openRead(start, end + 1)); + await res.close(); + } catch (_) {} + } + + List<_Call> sessions({String? token}) => [ + for (final c in calls) + if (c.path.startsWith('/Sessions/Playing') && + (token == null || authFields(c.auth)['Token'] == token)) + c + ]; + + List<_Call> streams(String token) => [ + for (final c in calls) + if (c.path.startsWith('/Videos/') && authFields(c.auth)['Token'] == token) c + ]; +} + +Future _poll(FutureOr Function() cond, String what, + {Duration timeout = const Duration(seconds: 30)}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 200)); + } + fail('timed out waiting for $what'); +} + +Future _account(String base, String token, String userId, String device) async { + final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device); + await a.ensureLoaded(validate: false); + await a.link( + baseUrl: base, + server: const JellyfinServerInfo(id: _sid, name: 'Fake Jellyfin', version: '10.11.11'), + auth: JellyfinAuthResult( + accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid), + ); + return a; +} + +String _kind(_Call c) => c.path.split('/').last; + +void main() { + test( + 'account holders direct-play and report, the guest plays through the relay, all in sync', + timeout: const Timeout(Duration(minutes: 4)), + () async { + MediaKit.ensureInitialized(); + final jelly = _FakeJellyfin(await _media()); + await jelly.start(); + const relayCred = (client: 'Watch Together', device: 'HostBox', deviceId: 'host-dev-1', version: '9.9.9'); + final server = SyncServer(AppConfig( + port: 0, + secret: 'jf', + name: 'jf', + jellyfin: JellyfinCredential( + baseUrl: jelly.base, + serverId: _sid, + token: 'HOSTTOKEN', + client: relayCred.client, + device: relayCred.device, + deviceId: relayCred.deviceId, + version: relayCred.version, + ), + )); + await server.start(); + final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC'); + final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC'); + final accB = JellyfinAccounts(store: MemoryJellyfinStore()); + await accB.ensureLoaded(validate: false); + final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); + final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); + final c = SessionCoordinator(jellyfin: accC, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); + final all = [a, b, c]; + ConnectParams params(String name, [HelloMedia? media]) => ConnectParams( + host: '127.0.0.1', port: server.boundPort, secret: 'jf', name: name, media: media); + try { + // A hosts the episode as a ref: no URL or token on the wire. + await a.connect(params('alice', const HelloMedia( + name: 'Pilot', + size: 0, + duration: 0, + jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot', subtitle: 'Fake Show · S1E1')))); + await _poll(() => a.serverState.peek()?.media?['jellyfin'] != null, 'ref on the server'); + expect(a.serverState.peek()!.media!.containsKey('url'), isFalse); + expect(a.serverState.peek()!.relay, isTrue); + await b.connect(params('guest')); + await c.connect(params('carol')); + for (final x in all) { + await x.playback.player.setVolume(0); + } + await _poll(() => all.every((x) => x.playback.durationSecs > 19), 'all three opened the item'); + expect(a.jellyfinPlan.peek(), isA()); + await _poll(() { + final members = a.serverState.peek()?.members ?? const []; + final jf = {for (final m in members) m.name: m.jf[_sid]}; + return jf['alice'] == true && jf['carol'] == true && jf['guest'] == false; + }, 'jf access in the member list'); + expect(a.nextEpisode.peek()?.item.id, _ep2); + expect(b.nextEpisode.peek(), isNull, reason: 'guests cannot resolve the next one'); + + for (final x in all) { + x.toggleReady(); + } + await _poll(() => all.every((x) => x.playback.player.state.playing), 'all playing'); + await Future.delayed(const Duration(seconds: 4)); + final pos = [for (final x in all) x.playback.positionSecs]; + // ignore: avoid_print + print('positions after 4 s: $pos'); + for (final p in pos) { + expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos'); + } + + // Pause (A unreadies): an immediate Progress with IsPaused. + a.toggleReady(); + await _poll(() => !a.playback.player.state.playing, 'held'); + await _poll( + () => jelly.sessions(token: 'ATOKEN').any((s) => + _kind(s) == 'Progress' && s.body?['IsPaused'] == true), + 'paused progress'); + a.toggleReady(); + await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again'); + + // Account holders see the "Next episode" prompt (this item is + // shorter than its 2 min lead); the guest cannot put it up. + await _poll(() => a.nextPromptVisible.peek(), 'next prompt'); + expect(b.nextPromptVisible.peek(), isFalse); + + // Alice and the guest pick English; it carries to the next episode. + await a.selectTrack(aid: 2); + await b.selectTrack(aid: 2); + await _poll( + () => jelly.sessions(token: 'ATOKEN').any((s) => + _kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2), + 'track change reported'); + + // Near the end: finished -> Stopped, then one auto-advance swap. + a.seekTo(16); + await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished', + timeout: const Duration(seconds: 20)); + final finishedId = a.serverState.peek()!.mediaId; + await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null, + 'both account holders count down'); + expect(b.autoAdvance.peek(), isNull); + final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper]; + expect(swappers.where((s) => s), hasLength(1), + reason: 'exactly one designated swapper: the lowest member id (alice, m1)'); + expect(a.autoAdvance.peek()!.swapper, isTrue); + await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced', + timeout: const Duration(seconds: 20)); + await Future.delayed(const Duration(seconds: 3)); + expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap'); + expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2); + await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null), + 'all opened the next episode'); + expect(b.jellyfinPlan.peek(), isA()); + expect((b.jellyfinPlan.peek() as RelayPlay).url, endsWith('/$_ep2')); + await _poll(() async => (await a.playback.currentTrackIds()).aid == 2, 'alice keeps English'); + await _poll(() async => (await b.playback.currentTrackIds()).aid == 2, 'guest keeps English'); + expect((await c.playback.currentTrackIds()).aid, 1, reason: 'carol never chose'); + expect(a.previousEpisode.peek()?.item.id, _ep1); + expect(a.nextEpisode.peek(), isNull); + + await a.leave(); + await c.leave(); + await Future.delayed(const Duration(milliseconds: 300)); + + // Progress only from account holders, exactly their own device. + final infoA = accA.clientInfo, infoC = accC.clientInfo; + void ownHeader(_Call call, JellyfinClientInfo info, String token) { + final f = _FakeJellyfin.authFields(call.auth); + expect(f, { + 'Client': info.client, + 'Device': info.device, + 'DeviceId': info.deviceId, + 'Version': info.version, + 'Token': token, + }, reason: '${call.method} ${call.path}'); + } + + final sessions = jelly.sessions(); + expect(sessions, isNotEmpty); + expect(sessions.every((s) => ['ATOKEN', 'CTOKEN'].contains(_FakeJellyfin.authFields(s.auth)['Token'])), + isTrue, reason: 'no reports from the guest or the relay credential'); + final aCalls = jelly.sessions(token: 'ATOKEN'); + for (final s in aCalls) { + ownHeader(s, infoA, 'ATOKEN'); + } + for (final s in jelly.sessions(token: 'CTOKEN')) { + ownHeader(s, infoC, 'CTOKEN'); + } + final ep1 = [for (final s in aCalls) if (s.body?['ItemId'] == _ep1) _kind(s)]; + final ep2 = [for (final s in aCalls) if (s.body?['ItemId'] == _ep2) _kind(s)]; + // ignore: avoid_print + print('alice reports: ep1 $ep1, ep2 $ep2'); + expect(ep1.first, 'Playing'); + expect(ep1, contains('Progress')); + expect(ep1.last, 'Stopped'); + expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); + final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); + expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond), + reason: 'played to the end'); + expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1'); + expect(ep2.first, 'Playing'); + expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); + final playing = aCalls.firstWhere((s) => _kind(s) == 'Playing'); + expect(playing.body!['AudioStreamIndex'], 1, reason: 'mpv aid 1 = Jellyfin index 1'); + expect(playing.body!['SubtitleStreamIndex'], -1); + final stopped2 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep2); + expect(stopped2.body!['PlaySessionId'], 'ps-ua-$_ep2'); + expect(aCalls.lastWhere((s) => _kind(s) != 'Stopped' && s.body?['ItemId'] == _ep2) + .body!['AudioStreamIndex'], 2, reason: 'English carried over and reported'); + + // Streams: own token with own fields; the relay with exactly the + // configured credential. + expect(jelly.streams('ATOKEN'), isNotEmpty); + for (final s in jelly.streams('ATOKEN')) { + ownHeader(s, infoA, 'ATOKEN'); + } + final relayed = jelly.streams('HOSTTOKEN'); + expect(relayed, isNotEmpty, reason: 'the guest played through the relay'); + for (final s in relayed) { + expect(_FakeJellyfin.authFields(s.auth), { + 'Client': relayCred.client, + 'Device': relayCred.device, + 'DeviceId': relayCred.deviceId, + 'Version': relayCred.version, + 'Token': 'HOSTTOKEN', + }); + } + expect(jelly.calls.any((c) => c.path.startsWith('/Videos/') && c.auth.isEmpty), isFalse, + reason: 'no token-less (api_key) stream requests'); + } finally { + for (final x in all) { + await x.dispose(); + } + await server.stop(); + await jelly.stop(); + } + }, + ); + + test( + 'a guest with no account and no relay sees the blocked state, then plays after linking', + timeout: const Timeout(Duration(minutes: 2)), + () async { + MediaKit.ensureInitialized(); + final jelly = _FakeJellyfin(await _media()); + await jelly.start(); + final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'nr')); + await server.start(); + final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC'); + final accB = JellyfinAccounts(store: MemoryJellyfinStore()); + await accB.ensureLoaded(validate: false); + final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); + final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); + try { + await a.connect(ConnectParams( + host: '127.0.0.1', + port: server.boundPort, + secret: '', + name: 'alice', + media: const HelloMedia( + name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1)))); + await b.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'bob')); + await _poll(() => b.jellyfinBlocked.peek() != null, 'blocked'); + expect(b.serverState.peek()!.relay, isFalse); + expect(b.jellyfinBlocked.peek()!.message, contains('needs a Jellyfin account')); + // Linking an account (Integrations) retries by itself. + await accB.link( + baseUrl: jelly.base, + server: const JellyfinServerInfo(id: _sid, name: 'Fake Jellyfin', version: '10.11.11'), + auth: const JellyfinAuthResult( + accessToken: 'CTOKEN', user: JellyfinUser(id: 'uc', name: 'uc'), serverId: _sid)); + await _poll(() => b.jellyfinPlan.peek() is OwnPlay && b.playback.durationSecs > 19, + 'plays with the new account'); + expect(b.jellyfinBlocked.peek(), isNull); + } finally { + await a.dispose(); + await b.dispose(); + await server.stop(); + await jelly.stop(); + } + }, + ); +} diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart new file mode 100644 index 0000000..a54f740 --- /dev/null +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -0,0 +1,591 @@ +import 'dart:convert'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/jellyfin/accounts.dart'; +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 'jellyfin_accounts_test.dart'; +import 'jellyfin_browser_test.dart' show app, setSize, settle; + +const episodeId = 'b18826be994070a79f9b52bc0f66f052'; // Black Summoner S1E6 +const nextEpisodeId = '74c50bdbf83e8fef231dd7710058e197'; // S1E7 +const seriesId = 'e9aec9c14a1fcab775111844112b4cf7'; +const seasonId = 'fb0b77cfda15445f9319ed9d753dba23'; +const playSessionId = 'fc428dbbeb9c46a7885fa053bd46f7e7'; + +const episodeRef = JellyfinRef( + serverId: fixtureServerId, itemId: episodeId, title: 'Rice and Thieves and Heroes'); + +/// The fake plus playback: PlaybackInfo, the Black Summoner episodes and +/// the three Sessions reports (recorded with their bodies). +FakeJellyfin playbackFake() { + final fake = FakeJellyfin(); + fake.fixture('POST', '/Items/$episodeId/PlaybackInfo', 'playback_info'); + fake.on('GET', '/Shows/$seriesId/Episodes', + (_, _) => JellyfinHttpResponse(200, jellyfinFixture('bs_episodes'))); + for (final path in ['/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped']) { + fake.on('POST', path, (_, body) { + reports.add((path: path, body: jsonDecode(body!) as Map)); + return const JellyfinHttpResponse(204, ''); + }); + } + return fake; +} + +final reports = <({String path, Map body})>[]; + +JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson( + jsonDecode(jellyfinFixture('playback_info')) as Map) + .primarySource!; + +MemberView member(String id, String name, {Map jf = const {}, bool stale = false}) => + MemberView( + id: id, name: name, ready: true, inSet: true, buffered: 10, stale: stale, jf: jf); + +void main() { + setUp(reports.clear); + + group('resolve a ref', () { + test('own account: PlaybackInfo with its own user, static stream, token in the header', + () async { + final (accounts, fake) = await linkedAccounts(http: playbackFake()); + final plan = await resolveJellyfinRef(accounts, episodeRef, + relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId'); + expect(plan, isA()); + plan as OwnPlay; + expect(plan.jfAccess, isTrue); + expect(plan.info.playSessionId, playSessionId); + expect(plan.source.id, episodeId); + expect(plan.item?.seriesId, seriesId); + final info = fake.requests.firstWhere((r) => r.uri.path.endsWith('/PlaybackInfo')); + expect(info.uri.queryParameters['userId'], fixtureUserId); + expect(info.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"')); + final url = Uri.parse(plan.url); + expect(url.path, '/Videos/$episodeId/stream'); + expect(url.queryParameters['static'], 'true'); + expect(url.queryParameters.containsKey('api_key'), isFalse, reason: 'token in a header only'); + expect(plan.headers['Authorization'], accounts.clientInfo.authorizationHeader('TEST_ACCESS_TOKEN')); + }); + + test('no account: the relay URL when the server relays', () async { + final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new); + final plan = await resolveJellyfinRef(accounts, episodeRef, + relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId'); + expect(plan, isA()); + expect((plan as RelayPlay).url, 'http://h:1/j/default/t/$episodeId'); + expect(plan.jfAccess, isFalse); + }); + + test('neither: blocked with a clear message, never a silent stall', () async { + final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new); + final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null); + expect(plan, isA()); + expect((plan as BlockedPlay).message, + 'This needs a Jellyfin account on that Jellyfin server (or the host can relay)'); + expect(plan.jfAccess, isNull); + }); + + test('a 401 marks the account expired and falls back to the relay', () async { + final fake = playbackFake() + ..on('POST', '/Items/$episodeId/PlaybackInfo', (_, _) => const JellyfinHttpResponse(401, '')); + final (accounts, _) = await linkedAccounts(http: fake); + final plan = await resolveJellyfinRef(accounts, episodeRef, relay: true, relayUrl: 'http://r'); + expect(plan, isA()); + expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired); + final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null); + expect((blocked as BlockedPlay).message, contains('on Example Server')); + expect(blocked.message, contains('sign-in expired')); + }); + + test('relay URL is built like the /m one', () { + expect(relayUrlFor('10.0.0.2', 8080, 'tok', episodeId), + 'http://10.0.0.2:8080/j/default/tok/$episodeId'); + }); + }); + + group('wire media', () { + test('a pick goes up as a ref without URL or token', () async { + final (accounts, _) = await linkedAccounts(); + const pick = JellyfinMedia( + serverId: fixtureServerId, + baseUrl: 'https://example.invalid', + itemId: episodeId, + title: 'Rice and Thieves and Heroes', + subtitle: 'Black Summoner · S1E6', + runtime: Duration(minutes: 23)); + final media = hostJellyfinMedia(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')), + throwsFormatException); + accounts.markExpired(fixtureServerId); + expect(() => hostJellyfinMedia(accounts, pick), throwsFormatException); + }); + + test('a pasted Jellyfin link becomes a ref (ServerId from /System/Info/Public, no api_key)', + () async { + final (accounts, fake) = await linkedAccounts(); + final media = await mediaForLink(accounts, + 'https://example.invalid/Videos/$episodeId/stream?static=true&api_key=SOMEONES_TOKEN'); + expect(media.url, isNull); + expect(media.jellyfin!.serverId, fixtureServerId); + expect(media.jellyfin!.itemId, episodeId); + expect(media.jellyfin!.title, 'Rice and Thieves and Heroes', reason: 'title via own account'); + expect(media.name, 'Rice and Thieves and Heroes'); + expect(jsonEncode(media.toJson()), isNot(contains('SOMEONES_TOKEN'))); + expect(fake.count('GET', '/System/Info/Public'), 1); + // Never sent with the pasted token. + expect(fake.requests.every((r) => !'${r.uri}${r.headers}'.contains('SOMEONES_TOKEN')), isTrue); + + // A web UI link carries the ServerId: no lookup. + final web = await mediaForLink(accounts, + 'https://example.invalid/web/#/details?id=$episodeId&serverId=$fixtureServerId'); + expect(web.jellyfin!.serverId, fixtureServerId); + expect(fake.count('GET', '/System/Info/Public'), 1); + }); + + test('other links, or an unreachable Jellyfin, stay plain links', () async { + final fake = FakeJellyfin()..offline = true; + final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake); + const url = 'https://jelly.example/Items/$episodeId/Download?api_key=x'; + final media = await mediaForLink(accounts, url); + expect(media.jellyfin, isNull); + expect(media.url, url); + expect(media.name, 'Download'); + final plain = await mediaForLink(accounts, 'https://cdn.example/movie.mkv'); + expect(plain.url, 'https://cdn.example/movie.mkv'); + expect(plain.name, 'movie.mkv'); + }); + + test('relay credential: the account token with exactly its own device fields', () async { + final (accounts, _) = await linkedAccounts(); + final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!; + final info = accounts.clientInfo; + expect(cred.token, 'TEST_ACCESS_TOKEN'); + expect(cred.serverId, fixtureServerId); + expect(cred.baseUrl, 'https://example.invalid'); + expect([cred.client, cred.device, cred.deviceId, cred.version], + [info.client, info.device, info.deviceId, info.version]); + expect(cred.device, 'Test PC'); + // Another server's item: the default account still relays its own. + expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId); + accounts.markExpired(fixtureServerId); + expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull); + final none = JellyfinAccounts(store: MemoryJellyfinStore()); + await none.ensureLoaded(validate: false); + expect(relayCredentialFor(none), isNull); + }); + }); + + group('progress', () { + late JellyfinAccounts accounts; + late DateTime now; + late JellyfinProgressReporter reporter; + final errors = []; + var unauthorized = 0; + + setUp(() async { + (accounts, _) = await linkedAccounts(http: playbackFake()); + now = DateTime(2026, 1, 1, 20); + errors.clear(); + unauthorized = 0; + reporter = JellyfinProgressReporter( + client: accounts.clientFor(fixtureServerId)!, + itemId: episodeId, + source: fixtureSource(), + playSessionId: playSessionId, + clock: () => now, + onError: errors.add, + onUnauthorized: () => unauthorized++, + ); + }); + + Future idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true)) + .then((_) {}); + + test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async { + // English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3). + reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1)); + void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) { + now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs)); + reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid)); + } + + at(1, 0); // resume -> Progress + at(2, 1); + at(5, 4); // nothing due + at(11, 10); // 10 s since the last one + at(12, 11, paused: true); // pause + at(13, 11, paused: true); + at(14, 300, paused: true); // seek + at(15, 300, paused: true, sid: null); // subtitles off + await reporter.stop(const PlayerSnapshot(position: 300.5, paused: true, aid: 2)); + + expect(reports.map((r) => r.path.split('/').last), [ + 'Playing', + 'Progress', // resumed + 'Progress', // 10 s + 'Progress', // paused + 'Progress', // seek + 'Progress', // track change + 'Stopped', + ]); + final playing = reports.first.body; + expect(playing['ItemId'], episodeId); + expect(playing['MediaSourceId'], episodeId); + expect(playing['PlaySessionId'], playSessionId); + expect(playing['PlayMethod'], 'DirectPlay'); + expect(playing['IsPaused'], isTrue); + expect(playing['AudioStreamIndex'], 2); + expect(playing['SubtitleStreamIndex'], 3); + expect(reports[2].body['PositionTicks'], 10 * jellyfinTicksPerSecond); + expect(reports[3].body['IsPaused'], isTrue); + expect(reports[4].body['PositionTicks'], 300 * jellyfinTicksPerSecond); + expect(reports[5].body['SubtitleStreamIndex'], -1); + expect(reports.last.body, { + 'ItemId': episodeId, + 'MediaSourceId': episodeId, + 'PlaySessionId': playSessionId, + 'PositionTicks': 3005000000, + }); + expect(errors, isEmpty); + }); + + test('played to the end, then seeked back: a fresh Playing', () async { + reporter.start(const PlayerSnapshot(position: 0, paused: false)); + await reporter.stop(const PlayerSnapshot(position: 1440, paused: true)); + expect(reporter.active, isFalse); + reporter.update(const PlayerSnapshot(position: 1440, paused: true)); + reporter.start(const PlayerSnapshot(position: 600, paused: false)); + await idle(); + expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped', 'Playing', 'Stopped']); + }); + + test('failures are handed on and never throw; a 401 ends reporting', () async { + final fake = playbackFake() + ..on('POST', '/Sessions/Playing/Progress', (_, _) => const JellyfinHttpResponse(500, '')); + (accounts, _) = await linkedAccounts(http: fake); + final r = JellyfinProgressReporter( + client: accounts.clientFor(fixtureServerId)!, + itemId: episodeId, + source: fixtureSource(), + playSessionId: playSessionId, + clock: () => now, + onError: errors.add, + onUnauthorized: () => unauthorized++, + ); + r.start(const PlayerSnapshot(position: 0, paused: false)); + r.update(const PlayerSnapshot(position: 0, paused: true)); + await r.stop(const PlayerSnapshot(position: 1, paused: true)); + expect(errors, hasLength(1)); + expect(unauthorized, 0); + expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped']); + + fake.on('POST', '/Sessions/Playing', (_, _) => const JellyfinHttpResponse(401, '')); + reports.clear(); + r.start(const PlayerSnapshot(position: 0, paused: false)); + r.update(const PlayerSnapshot(position: 0, paused: true)); + await r.stop(const PlayerSnapshot(position: 1, paused: true)); + expect(unauthorized, 1); + expect(reports, isEmpty, reason: 'nothing after the 401'); + expect(r.active, isFalse); + }); + }); + + group('episodes', () { + test('neighbours of an episode with the clicker\'s account', () async { + final (accounts, _) = await linkedAccounts(http: playbackFake()); + final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null) + as OwnPlay; + final around = await findAdjacentEpisodes(plan); + expect(around.previous, isNull, reason: 'S1E6 is first in the fixture'); + expect(around.next!.item.id, nextEpisodeId); + expect(around.next!.label, 'S1E7 Battle with the Heroes'); + final media = mediaForPick(around.next!.pick); + expect(media.jellyfin!.itemId, nextEpisodeId); + expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); + }); + + test('the floating prompt starts two minutes before the end', () { + expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22)); + expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero); + }); + + test('one auto-advance swapper: the lowest id among members with their own account', () { + final members = [ + member('m10', 'Ann', jf: {fixtureServerId: true}), + member('m2', 'Bob', jf: {fixtureServerId: true}), + member('m1', 'Guest', jf: {fixtureServerId: false}), + member('m0', 'Old', jf: {fixtureServerId: true}, stale: true), + member('m3', 'Fresh'), + ]; + expect(autoAdvanceSwapper(members, fixtureServerId), 'm2'); + expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2', + reason: 'order does not matter: every client agrees'); + expect(autoAdvanceSwapper(members, 'other-server'), isNull); + expect(compareMemberIds('m9', 'm10'), lessThan(0)); + }); + + test('member badge: via host only for relay viewers of the current item', () { + expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue); + expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse); + expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse); + expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse); + }); + }); + + group('track carry-over', () { + test('choice from Jellyfin streams carries to the next episode by traits', () { + final source = fixtureSource(); + // mpv aid 2 / sid 2: English eac3 audio, "English (Signs)" subtitles. + final choice = TrackChoice.of(aid: 2, sid: 2, source: source); + expect(choice.audio!.language, 'eng'); + expect(choice.subtitle!.title, 'English (Signs) [Generated]'); + // Next file: same tracks in another order (English audio first). + final next = JellyfinMediaSource(id: 'n', mediaStreams: [ + const JellyfinMediaStream(index: 0, type: JellyfinStreamType.video), + const JellyfinMediaStream( + index: 1, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'eac3', channels: 2), + const JellyfinMediaStream( + index: 2, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'eac3', channels: 2), + const JellyfinMediaStream( + index: 3, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Full Subtitle)'), + const JellyfinMediaStream( + index: 4, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Signs)'), + ]); + final pick = chooseTracks(choice, source: next); + expect(pick.aid, 1, reason: 'English is mpv aid 1 there'); + expect(pick.sid, 2, reason: 'Signs, not Full'); + expect(pick.subtitlesOff, isFalse); + final off = chooseTracks(TrackChoice.of(aid: 1, sid: null, source: source), source: next); + expect(off.aid, 2, reason: 'Japanese'); + expect(off.subtitlesOff, isTrue); + }); + + test('relay viewers match on mpv\'s track list', () { + const tracks = [ + MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'aac'), + MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'aac'), + MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Signs'), + MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Full'), + ]; + final choice = TrackChoice.of(aid: 2, sid: 2, tracks: tracks); + expect(choice.audio!.title, 'English'); + final pick = chooseTracks(choice, tracks: const [ + MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'en', title: 'English'), + MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'ja', title: 'Japanese'), + MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'en', title: 'Full Subtitles'), + MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'en', title: 'Signs & Songs'), + ]); + expect(pick.aid, 1); + expect(pick.sid, 1); + }); + + test('kept per series with a global fallback', () async { + final prefs = TrackPrefs(store: MemoryJellyfinStore()); + expect(await prefs.load(seriesId: 'a'), isNull); + final eng = TrackChoice.of(aid: 2, sid: null, source: fixtureSource()); + await prefs.save(eng, seriesId: 'a'); + final jpn = TrackChoice.of(aid: 1, sid: 1, source: fixtureSource()); + await prefs.save(jpn, seriesId: 'b'); + expect((await prefs.load(seriesId: 'a'))!.audio!.language, 'eng'); + expect((await prefs.load(seriesId: 'a'))!.subtitlesOff, isTrue); + expect((await prefs.load(seriesId: 'b'))!.audio!.language, 'jpn'); + expect((await prefs.load(seriesId: 'new'))!.audio!.language, 'jpn', reason: 'global = last'); + expect((await prefs.load())!.subtitle!.title, 'English (Full Subtitle)'); + }); + }); + + group('browser location', () { + test('route stacks round-trip and survive in the Jellyfin box', () async { + final (accounts, _) = await linkedAccounts(); + final stack = [ + const HomeRoute(), + const LibraryRoute( + JellyfinItem(id: 'lib1', name: 'Shows', type: 'CollectionFolder', collectionType: 'tvshows')), + SeriesRoute(const JellyfinItem(id: seriesId, name: 'Black Summoner', type: 'Series'), + seasonId: seasonId), + ]; + await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack(stack)); + final back = decodeBrowseStack(accounts.browseLocation(fixtureServerId))!; + expect(back.map((r) => r.key), stack.map((r) => r.key)); + expect((back[1] as LibraryRoute).folder.collectionType, 'tvshows'); + expect((back[2] as SeriesRoute).seasonId, seasonId); + expect(decodeBrowseStack('junk'), isNull); + }); + + testWidgets('the browser reopens where it was, per server', (tester) async { + await setSize(tester, const Size(1600, 1000)); + final (accounts, _) = await linkedAccounts(); + await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([ + const HomeRoute(), + SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'), + seasonId: season2Id), + ])); + final c = MediaPickerController(source: MediaKind.jellyfin); + addTearDown(c.dispose); + await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts))); + await settle(tester); + final s2 = tester.widget(find.byKey(const ValueKey('season-$season2Id'))); + expect(s2.selected, isTrue); + // Back to Home is remembered too. + await tester.tap(find.byTooltip('Back (Esc)')); + await settle(tester); + expect(decodeBrowseStack(accounts.browseLocation(fixtureServerId))!.map((r) => r.key), ['home']); + }); + }); + + group('session widgets', () { + final next = EpisodeLink( + const JellyfinItem( + id: nextEpisodeId, name: 'Battle with the Heroes', type: 'Episode', + parentIndexNumber: 1, indexNumber: 7), + const JellyfinMedia( + serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); + + testWidgets('next / previous buttons: tooltip, hidden without an episode', (tester) async { + var pressed = 0; + await tester.pumpWidget(app(Row(children: [ + EpisodeSkipButton(episode: null, next: false, onPressed: () {}), + EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++), + ]))); + expect(find.byKey(const ValueKey('episode-previous')), findsNothing); + expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('episode-next'))); + expect(pressed, 1); + }); + + testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { + var play = 0, cancel = 0; + await tester.pumpWidget(app(EndOfEpisodeCard( + next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))); + expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget); + expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing); + await tester.tap(find.byKey(const ValueKey('play-next'))); + expect(play, 1); + + await tester.pumpWidget(app(EndOfEpisodeCard( + next: next, + countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false), + onPlayNow: () => play++, + onCancel: () => cancel++))); + expect(find.text('Next episode in 7 s'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('auto-advance-cancel'))); + expect(cancel, 1); + }); + + testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async { + var pressed = 0; + await tester.pumpWidget(app(Align(child: NextEpisodePill(episode: next, onPressed: () => pressed++)))); + double opacity() => tester.widget(find.descendant( + of: find.byType(NextEpisodePill), matching: find.byType(AnimatedOpacity))).opacity; + expect(opacity(), lessThan(0.5)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('next-episode-pill')))); + await tester.pumpAndSettle(); + expect(opacity(), 1); + await tester.tap(find.byKey(const ValueKey('next-episode-pill'))); + expect(pressed, 1); + }); + + testWidgets('blocked card offers to link an account', (tester) async { + var link = 0; + await tester.pumpWidget(app(JellyfinBlockedCard( + blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'), + onLinkAccount: () => link++, + onRetry: () {}))); + expect(find.text('This needs a Jellyfin account on Example Server (or the host can relay)'), + findsOneWidget); + await tester.tap(find.byKey(const ValueKey('link-jellyfin-account'))); + expect(link, 1); + }); + + testWidgets('swap panel: the shared picker, File only for the host', (tester) async { + await setSize(tester, const Size(1400, 900)); + final (accounts, _) = await linkedAccounts(); + final c = MediaPickerController(source: MediaKind.link); + addTearDown(c.dispose); + final swapped = []; + var closed = 0; + await tester.pumpWidget(app(SwapPanel( + controller: c, + accounts: accounts, + isHost: false, + onSwap: (m) async { + swapped.add(m); + return null; + }, + onClose: () => closed++, + ))); + await settle(tester); + expect(find.widgetWithText(ButtonSegment, 'File'), findsNothing); + expect(find.text('File'), findsNothing); + expect(find.text('Jellyfin'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv'); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('swap-go'))); + await settle(tester); + expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv')]); + expect(closed, 1); + + // The Jellyfin source is the library browser; a pick goes up as is. + await tester.tap(find.text('Jellyfin')); + await settle(tester); + expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget); + + await tester.pumpWidget(app(SwapPanel( + controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {}))); + await settle(tester); + expect(find.text('File'), findsOneWidget); + }); + + testWidgets('swap panel opens on the series of the episode playing, episode highlighted', + (tester) async { + await setSize(tester, const Size(1600, 1000)); + final (accounts, _) = await linkedAccounts(); + // Somewhere else remembered: the series start wins. + await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([const HomeRoute()])); + final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List; + final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map) + .cast()); + final pick = pickForRef(accounts, + JellyfinRef(serverId: fixtureServerId, itemId: ep.id, title: ep.name))!; + final c = MediaPickerController(jellyfin: pick, source: MediaKind.jellyfin); + addTearDown(c.dispose); + await tester.pumpWidget(app(SwapPanel( + controller: c, + accounts: accounts, + 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, + isTrue); + expect(find.text('Put up: ${pick.label}'), findsOneWidget); + final row = tester.widget(find + .ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material)) + .first); + expect((row.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent), + reason: 'the episode playing is highlighted'); + }); + }); +} diff --git a/apps/desktop/test/server_isolate_test.dart b/apps/desktop/test/server_isolate_test.dart index be1fd36..baae6cd 100644 --- a/apps/desktop/test/server_isolate_test.dart +++ b/apps/desktop/test/server_isolate_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; import 'package:media_kit/media_kit.dart'; import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/state/server_isolate.dart'; import 'package:watch_together/src/state/session_state.dart'; @@ -162,4 +163,59 @@ void main() { await session.stopServer(); } }); + + test('host path: a Jellyfin pick goes up as a ref with the host relay; File swap', + timeout: const Timeout(Duration(minutes: 2)), () async { + MediaKit.ensureInitialized(); + final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; + Hive.init('$tmp/hive_isolate_jf_test'); + const sid = 'abcdefabcdefabcdefabcdefabcdef12'; + final accounts = JellyfinAccounts.instance; + await accounts.ensureLoaded(validate: false); + // An unreachable Jellyfin: only the wire and the relay flag matter here. + await accounts.link( + baseUrl: 'http://127.0.0.1:9', + server: const JellyfinServerInfo(id: sid, name: 'J', version: '10.11.11'), + auth: const JellyfinAuthResult( + accessToken: 'HOSTTOKEN', user: JellyfinUser(id: 'u', name: 'u'), serverId: sid)); + final session = SessionState.instance; + final port = await _freePort(); + _Raw? friend; + final file = File('$tmp/host file swap.mkv')..writeAsBytesSync(List.filled(4096, 7)); + try { + await session.host( + ConnectParams( + host: '', + port: port, + secret: '', + name: 'host', + media: const HelloMedia( + name: 'Pilot', + size: 0, + duration: 0, + jellyfin: JellyfinRef(serverId: sid, itemId: 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1', title: 'Pilot'))), + ConnectForm()); + await session.coordinator.playback.player.setVolume(0); + friend = await _Raw.join(port, 'friend'); + final state = await friend.next('state', where: (m) => m['media'] != null); + final media = state['media'] as Map; + expect(media['jellyfin'], isA()); + expect(media.containsKey('url'), isFalse); + expect(state['relay'], isTrue, reason: "the host's own sign-in relays for guests"); + expect(jsonEncode(state), isNot(contains('HOSTTOKEN'))); + + expect(await session.coordinator.swapFile(file.path), isNull); + final swapped = await friend.next('state', + where: (m) => (m['media'] as Map?)?['name'] == 'host file swap.mkv'); + expect((swapped['media'] as Map)['jellyfin'], isNull); + expect(swapped['relay'], isNull); + expect(await session.coordinator.swapFile('$tmp/missing.mkv'), contains('not found')); + } finally { + await friend?.ws.close(); + await session.coordinator.leave(silent: true); + await session.stopServer(); + await accounts.signOut(sid); + if (file.existsSync()) file.deleteSync(); + } + }); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index daec5f8..9b611d6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,7 +26,10 @@ an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a `SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port: `ready` (with the port and a `stop` SendPort), `error` (start failed), and every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes -all sockets and returns, and the isolate exits. +all sockets and returns, and the isolate exits. `{'t':'file', 'path', 'reply'}` +on the same port makes a local file the hosted one and answers its name and +size (`ServerIsolate.setFile`): the host's in-session File swap then sends a +`swap` with that name and size, which the server matches to the file. - **Hosting from the app**: `SessionState.host()` spawns an isolate running `serve` (`ServerIsolate`), waits for `ready`, then joins it over @@ -96,7 +99,10 @@ Loosely modeled on Syncplay, with a different readiness design. position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in `state` for that event); flags are untouched. Clients diff each `state` broadcast by `eventId` (`StateDiff`): new event means seek to the computed - server position, transport change means play/pause. Between events: drift + server position, transport change means play/pause. The coordinator + applies states one at a time, newest first (a state arriving meanwhile + replaces any still waiting): interleaved applies could land an older + pause after a newer play. Between events: drift over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95 until level, otherwise nothing. - **User seeks**: the player is a `SyncPlayer` whose public `seek` is the @@ -141,7 +147,10 @@ Three sources, same sync. - **Link**: the media field holds an http(s) URL. It travels as `media.url` in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it - directly; the server is only the clock. + directly; the server is only the clock. A pasted link that + `parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the + ServerId is known (in the link, else `/System/Info/Public`), so its + `api_key` is never shared (`mediaForLink`, host and swap alike). - **Host local file**: `SyncServer.setLocalFile` / `mediaPath` makes the file the session media. `GET /m///` serves it with a Range table, gated by the viewer's token. The server builds the URL per viewer @@ -319,6 +328,66 @@ errors leave it out). subscription does). - No progress reporting in the server: watched state is per client. +## Jellyfin in a session + +Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, +`track_prefs.dart`, wired in `SessionCoordinator`): +- **Resolve**: a `state` whose media is a ref is resolved per viewer + (`resolveJellyfinRef`): an account for that ServerId that is not expired + -> PlaybackInfo with its own user (PlaySessionId, media source; `/Items/{id}` + alongside for series and episode numbers), then mpv plays the static + stream with the token in `http-header-fields` (`Authorization: + MediaBrowser` with this install's fields), not in the URL: `OwnPlay`, + `report.jf {serverId: true}`. Else, when `state.relay`, `welcome.relayUrl` + or `/j/default//` on the dialled host: `RelayPlay`, + `jf: false`. Else `BlockedPlay`: the session shows "This needs a + Jellyfin account on (or the host can relay)" with a button that + opens Integrations over the session; linking (or reconnecting) the + account retries by itself. A 401 marks the account expired. The member + list shows "via host" for members whose `jf` is false for the current + item. A reconnect to the same item with `OwnPlay` keeps playing as is. +- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the + file is open and mpv has picked its tracks; Progress every 10 s and at + once on pause, resume, a seek (position more than 3 s from where playback + should be) or a track change; Stopped on swap, leave, kick, window close + (bounded to 1.5 s inside the graceful leave) and at `finished` (position = + duration, so Jellyfin marks it played); seeking back from the end sends + Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's + `aid`/`sid` as Jellyfin stream indices (`jellyfinIndexForMpvTrack`, + `SubtitleStreamIndex -1` = off). They go out in order on one chain; + failures are logged, one quiet toast per session, playback is never + touched; a 401 marks the account expired and ends reporting. +- **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one + `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the + previous and next episode. The controls show previous / next buttons + (also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (last 2 min of + the runtime; the single place to switch to an outro segment later) a + floating "Next episode" pill shows outside the auto-hiding controls. Any + of them swaps to that episode's ref with the clicker's account. Guests + (relay, blocked) see none of it. +- **Auto-advance** (Settings, on by default): at `finished` with a known + next episode, every client with the setting on shows a 10 s countdown + card (Cancel / Play now; without the setting, a "Next" card with Play + next). Exactly one client swaps: `autoAdvanceSwapper`, the lowest member + id among non-stale members whose `jf` for that server is true. Only what + every client sees in `state` decides it (who put the item up is not in + `state`; a late joiner never saw that swap), so clients agree without a + wire change. Cancel is local: it stops the countdown on that screen, and + the swapper's Cancel stops the swap; a seek by anyone ends `finished` + and with it every countdown. +- **Tracks**: the controls' subtitles button lists mpv's audio and subtitle + tracks. A pick is applied, reported, and saved as a `TrackChoice` + (language, title, codec, channels, forced/HI: from the Jellyfin streams + with `OwnPlay`, else from mpv's track list) per series and as the global + fallback in the `track_prefs` Hive box. On the next Jellyfin item it is + 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 + 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`): @@ -339,7 +408,7 @@ 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, updates, video shaders, shortcuts), Integrations +decoding, 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 @@ -369,11 +438,17 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): 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. Interim until the protocol - carries Jellyfin items: `interimJellyfinLink` in `host_tab.dart` - (`INTERIM(jellyfin-protocol)`) hosts it as a link to the host's - authorised static stream URL; `ConnectFlow.submit(linkOverride:)` keeps - that token-bearing URL out of the saved form. + 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 + 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 + season selected and the episode highlighted. 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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 17acd96..5152e86 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -46,7 +46,8 @@ and `e2e_check` (simulated viewers against a live server). - `cd packages/core && dart test`, `cd packages/server && dart test`. - `cd apps/desktop && flutter test test/`. - Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`, - `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`. The sync and + `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a + local fake Jellyfin over HTTP; tests never talk to a real Jellyfin). The sync and end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't open video-only files, so test media needs an audio track. Tests always log diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index b99a64b..46d64c2 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -17,7 +17,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format: +----------------------------------------+ video bytes come from somewhere else: - - a link (Jellyfin, any http/https URL): each app fetches it directly + - a link (any http/https URL): each app fetches it directly + - a Jellyfin item: each app plays it with its own Jellyfin account, + friends without one through the server (/j/... endpoint) - a file on the host's disk: the server streams it (/m/... endpoint) ``` @@ -26,8 +28,10 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format: - **Server**: keeps one shared clock and the list of people. It never sees video frames, only "position 123 s, playing". There is exactly one server and one watch session on it. -- **Jellyfin** (or any web host): where a link's video lives. The server does - not touch it today; each app downloads it itself. +- **Jellyfin** (or any web host): where the video lives. For a Jellyfin + item the server only knows which item it is; each app signs in with its + own account and reports what it watched. For friends without an account + the server fetches the item with the host's sign-in and passes it on. ## Ways to run @@ -71,7 +75,9 @@ anyone except the host. ## Where video comes from - **Link**: you paste an http(s) URL. Every app plays it directly with mpv. - Works for a Jellyfin direct-download link or any file on the web. + A pasted Jellyfin link becomes the Jellyfin item below when possible. +- **Jellyfin item**: picked in the app's library browser. Shared as "this + item on this Jellyfin server", never as a link with someone's sign-in. - **Local file (host only)**: you pick a file on your disk. The server streams it to everyone, host included, over HTTP with range requests, each with a per-person token. Nothing is stored or converted. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 57ffc8e..10f3360 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,8 +24,7 @@ off by deleting it in the same commit as the code. Wire changes also edit next suspect is media_kit's context switching on the raster thread with NVIDIA. - Link conveniences: a link swap in session updates the remembered media; - show the open link with an open-in-browser button; the session's swap - panel reuses Home's `MediaPicker`. + show the open link with an open-in-browser button. - Time UI: remaining time, scrubber preview. - mpv / mpv.net shortcut parity. - Sounds: replace `NoopSfxService` (cues already fire). Candidates: @@ -38,32 +37,25 @@ off by deleting it in the same commit as the code. Wire changes also edit - 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`. + `api_key` (recognised Jellyfin links become refs; other links do not). - TLS: document a reverse proxy in front of the server. - Playlist: `queue-add`, `queue-skip`, auto-advance like a swap. - Sidecar subtitles. ## Epic: Jellyfin -- Each viewer logs in to Jellyfin themselves (Quick Connect or password). -- Several Jellyfin servers can be linked, keyed by `ServerId`. -- The sync server shares only `{ServerId, ItemId}`. Pasted Jellyfin links are - recognised and reduced to that pair. -- Viewers with an account direct-play `/Videos/{id}/stream?static=true` - with their own token and report Playing / Progress / Stopped, so watched - state and Continue Watching update per account. -- Guests without an account play through the sync server, which fetches with a - credential that stays server-side (the host's own or a configured one). -- Next episode: the successor in `/Shows/{seriesId}/Episodes` (NextUp means - first unwatched, not next-after-X); audio and subtitle track matching by language, then title, - then codec/channels. -- Library browser; anyone can put up items. - -Spike facts (Jellyfin 10.11.11): `/Videos/{id}/stream?static=true&mediaSourceId=` -serves the original with byte ranges (206). PlaybackInfo gives `PlaySessionId` -and DirectPlay. All audio and subtitle tracks are embedded in the mkv. Quick -Connect is enabled but `/QuickConnect/Initiate` returned 400: investigate -(likely missing client/device fields in the auth header). +What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin +in a session"): +- Queue: put several library items (a season, a pick list) up as a + playlist that advances like a swap (shares `queue-add` / `queue-skip` + with the backlog's Playlist item). +- The "Next episode" prompt from the item's Outro / credits segment instead + of the last two minutes (`nextPromptStart`). +- Auto-advance cancel for everyone: today Cancel is per screen (the + designated swapper's Cancel stops the swap; a seek stops all). A shared + cancel needs a wire message. +- Sidecar (external) subtitle streams of Jellyfin items: only embedded + tracks are mapped to mpv today. ## Deferred diff --git a/packages/core/lib/src/jellyfin/client.dart b/packages/core/lib/src/jellyfin/client.dart index 53428b0..3eac53b 100644 --- a/packages/core/lib/src/jellyfin/client.dart +++ b/packages/core/lib/src/jellyfin/client.dart @@ -426,6 +426,16 @@ class JellyfinClient { await episodes(seriesId, withMediaStreams: withMediaStreams), episode.id); } + /// The episodes before and after [episode] in its series (one + /// `/Shows/{id}/Episodes` call; see [episodeBefore] / [episodeAfter]). + Future<({JellyfinItem? previous, JellyfinItem? next})> adjacentEpisodes( + JellyfinItem episode) async { + final seriesId = episode.seriesId; + if (seriesId == null) return (previous: null, next: null); + final list = await episodes(seriesId); + return (previous: episodeBefore(list, episode.id), next: episodeAfter(list, episode.id)); + } + // -------------------------------------------------------------- images /// Image URL for caching and display. Images need no token on Jellyfin, @@ -594,3 +604,20 @@ JellyfinItem? episodeAfter(List episodes, String episodeId) { } return null; } + +/// The predecessor of [episodeId] in [episodes], the mirror of +/// [episodeAfter]: crossing seasons, skipping other copies of the same +/// episode. Null when it is first or not in the list. +JellyfinItem? episodeBefore(List episodes, String episodeId) { + final at = episodes.indexWhere((e) => e.id == episodeId); + if (at < 0) return null; + final cur = episodes[at]; + for (var i = at - 1; i >= 0; i--) { + final e = episodes[i]; + final sameEpisode = cur.indexNumber != null && + e.indexNumber == cur.indexNumber && + e.parentIndexNumber == cur.parentIndexNumber; + if (!sameEpisode) return e; + } + return null; +} diff --git a/packages/core/test/jellyfin_client_test.dart b/packages/core/test/jellyfin_client_test.dart index 2e6fe72..3771f53 100644 --- a/packages/core/test/jellyfin_client_test.dart +++ b/packages/core/test/jellyfin_client_test.dart @@ -235,6 +235,22 @@ void main() { expect(episodeAfter(eps, eps.last.id), isNull); expect(episodeAfter(eps, 'nope'), isNull); }); + + test('adjacentEpisodes: previous mirrors next across seasons and copies', () async { + http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes'); + final eps = await client.episodes('550eff23b37ec61082c988678ccc214c'); + final s3e2 = eps.firstWhere((e) => e.episodeLabel == 'S3E2'); + final around = await client.adjacentEpisodes(s3e2); + // S3E1 exists twice: the predecessor is the copy right before S3E2. + expect(around.previous!.episodeLabel, 'S3E1'); + expect(around.next!.episodeLabel, 'S3E3'); + final s3e1 = eps.lastWhere((e) => e.episodeLabel == 'S3E1'); + expect(episodeBefore(eps, s3e1.id)!.episodeLabel, 'S2E12'); + expect(episodeBefore(eps, eps.first.id), isNull); + expect(episodeBefore(eps, 'nope'), isNull); + expect((await client.adjacentEpisodes(const JellyfinItem(id: 'x', name: 'm', type: 'Movie'))).next, + isNull); + }); }); group('images', () { diff --git a/packages/server/lib/src/serve.dart b/packages/server/lib/src/serve.dart index 39b00b7..e553e0b 100644 --- a/packages/server/lib/src/serve.dart +++ b/packages/server/lib/src/serve.dart @@ -14,7 +14,11 @@ import 'sync_server.dart'; /// `{'t':'error','message':m}`, and forwards log records as /// `{'t':'log','level','logger','message'[,'error']}`. Sending /// `{'t':'stop'}` to the `stop` port stops the server; the returned future -/// completes once it is down. Without [events] a start failure throws. +/// completes once it is down. `{'t':'file','path':p,'reply':SendPort}` makes +/// a file on this machine the hosted file (the hosting app's in-session file +/// swap; a following `swap` with its name and size puts it up) and answers +/// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`. Without [events] a +/// start failure throws. Future serve(Map config, {SendPort? events}) async { StreamSubscription? logs; if (events != null) { @@ -39,7 +43,20 @@ Future serve(Map config, {SendPort? events}) async { final control = ReceivePort('serve control'); events?.send({'t': 'ready', 'port': server.boundPort, 'stop': control.sendPort}); await for (final msg in control) { - if (msg is Map && msg['t'] == 'stop') break; + if (msg is! Map) continue; + if (msg['t'] == 'stop') break; + if (msg['t'] == 'file' && msg['path'] is String && msg['reply'] is SendPort) { + final reply = msg['reply'] as SendPort; + try { + final ok = await server.setLocalFile(msg['path'] as String); + final media = server.session.media; + reply.send(ok && media != null + ? {'ok': true, 'name': media.name, 'size': media.size} + : {'ok': false}); + } catch (_) { + reply.send({'ok': false}); + } + } } control.close(); await server.stop(); diff --git a/packages/server/test/serve_isolate_test.dart b/packages/server/test/serve_isolate_test.dart index 35c4025..c24cfc1 100644 --- a/packages/server/test/serve_isolate_test.dart +++ b/packages/server/test/serve_isolate_test.dart @@ -51,6 +51,31 @@ void main() { errors.close(); }); + test('serve: the file control message makes a local file the hosted one', () async { + final events = ReceivePort(); + final exit = ReceivePort(); + final dir = Directory.systemTemp.createTempSync('serve_file_'); + final file = File('${dir.path}/ep 2.mkv')..writeAsBytesSync(List.filled(1000, 1)); + await Isolate.spawn(_run, (const AppConfig(port: 0, secret: '', name: 'f').toMap(), events.sendPort), + onExit: exit.sendPort); + final ready = await events.firstWhere((m) => m is Map && m['t'] == 'ready') as Map; + final stop = ready['stop'] as SendPort; + Future ask(String path) async { + final reply = ReceivePort(); + stop.send({'t': 'file', 'path': path, 'reply': reply.sendPort}); + final r = await reply.first.timeout(const Duration(seconds: 5)); + reply.close(); + return r; + } + + expect(await ask(file.path), {'ok': true, 'name': 'ep 2.mkv', 'size': 1000}); + expect(await ask('${dir.path}/missing.mkv'), {'ok': false}); + stop.send({'t': 'stop'}); + await exit.first.timeout(const Duration(seconds: 10)); + events.close(); + dir.deleteSync(recursive: true); + }); + test('serve reports a bind failure as an error event', () async { final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0); final events = ReceivePort();