diff --git a/README.md b/README.md index 6a24ab1..a183903 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,10 @@ Up next, add the folder to Files, or fill Link). "Show (Season 3 - Alicization)" gives the season and its name. Shows are looked up on AniList for their proper name, cover and banner. A show's page has its seasons and episodes and a button for the next one - after what you last played here; when episodes are missing on the way - it says so ("66–107 aren't in your folders — skip to 108?"). Matched + after what you last watched here; when episodes are missing on the way + it says so ("66–107 aren't in your folders — skip to 108?"). An + episode you put up continues with the next one at its end (see Up + next). Matched the wrong show? **Wrong show?** searches AniList and pins the right one for the show everywhere or just one folder; **Episode numbers…** says which episode the first file is when the files count differently. Both @@ -253,7 +255,7 @@ app open for that. Everyone shares one Up next and anyone can change it; what you added stays when you leave. In the player, Up next sits in the top bar, right of the title: the -next item's picture and title (or the next episode's). Rest the mouse on +next item's picture and title (or what continues the episode). Rest the mouse on it for a larger preview (who added it, its length, whether it is ready to start); ⏭ plays it now, a click opens the queue: drag rows to reorder, hover a row to play it now (▶) or remove it (×), right-click it for more (copy its @@ -261,17 +263,22 @@ title or link, open it in the browser or in Jellyfin). To add, press Ctrl+V with the queue open (a copied link or video file goes in at once, its title and picture showing while it is checked), type one after **+ Add link**, or drop a link or a video on it. When a video ends, a card shows -what is up next with a 10 s countdown (Cancel stops it on your screen) -and it plays; `>` or Shift+N plays it at once. Under the queue, -**Continues with** shows the next episodes of the Jellyfin series -playing (greyed: they play only once Up next is empty); **+** adds one, -**Clear** drops them for this episode. +what is up next with a 10 s countdown everyone sees, and it plays; +Cancel stops it for everyone, and so does seeking back. `>` or Shift+N +plays it at once. Under the queue, **Continues with** shows what +continues the episode playing: its next episode (a Jellyfin episode's, +or a file's next one in your Files library, saying which numbers it +skips), added by whoever put the episode up, and greyed because it +plays only once Up next is empty; **Clear** drops it for everyone. The +further episodes of a Jellyfin series show under it; **+** adds one. A link you pass on ("through me") or a file of yours can only be played by your app: if you left when its turn comes, it is skipped ("Skipped …: -Foxyz left"). A Jellyfin item needs someone with an account on that -server. Up next is kept by the server while it runs; restarting the -server empties it. +Foxyz left"). If you are only gone for a moment, the countdown waits for +you. A Jellyfin item needs someone with an account on that server. Up +next is kept by the server while it runs. If the server restarts, the +apps put back what was playing, where it was, and Up next with it (a +link the server fetched has to be added again). ## Schedule @@ -473,10 +480,13 @@ Watching: 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). Watched means you actually played most of - it (or reached the ending after half), not where you stopped: opening - a watched episode to check a moment keeps it watched, and a quick peek - leaves no resume point. + change, and when it stops). Watched means you were there while most + of it played (or while half of it played and it reached the ending), + not where it stopped: time you sat out does not count, skipping around + does not count, opening a watched episode to check a moment keeps it + watched, and a quick peek leaves no resume point. Files and links you + watch are remembered the same way on your computer (a show's next + episode in Files follows them). - Friends without an account watch through the server: whoever put the item up passes it on automatically, with their own sign-in, which never leaves their computer (only the current item, only to people in the @@ -493,12 +503,11 @@ Episodes: the ⏮ / ⏭ buttons next to play (or `<` / `>`, Shift+P / Shift+N) put up the previous or next episode for everyone. From the ending (when it is known, see Skipping above; else in the last two minutes) a "Next episode" button floats in the corner while the controls -are up. 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. +are up. With "Play the next episode automatically" (Settings, on by +default), the episode you put up continues with the next one: when it +ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up +next). Anyone with an account can put the next one up; friends watching +via the host cannot. Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks. Your choice is remembered per series (and as a default) and the closest diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index b11936b..1e8fc55 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -66,8 +66,10 @@ Future main(List args) async { } catch (e, st) { debugPrint('load connect form failed: $e\n$st'); } - // The library's matches, pins and offsets (device-owned). + // The library's matches, pins and offsets, and the watch history + // (device-owned). unawaited(graph.library.ensureLoaded()); + unawaited(graph.watch.history.ensureLoaded()); ports.players.init(); thumbnailPlayers = ports.players; // yt-dlp for web page links: downloaded on first run, kept current. diff --git a/apps/desktop/lib/src/app/app_graph.dart b/apps/desktop/lib/src/app/app_graph.dart index c74a7b3..00a6522 100644 --- a/apps/desktop/lib/src/app/app_graph.dart +++ b/apps/desktop/lib/src/app/app_graph.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../jellyfin/accounts.dart'; import '../library/library_store.dart'; +import '../library/local_next.dart'; import '../library/show_matcher.dart'; import '../platform/app_log.dart' show appLogDir; import '../platform/desktop/desktop_ports.dart'; @@ -20,6 +21,8 @@ import '../sfx/sound_bank.dart'; import '../sfx/synth_sfx.dart'; import '../state/connect_prefs.dart'; import '../state/settings.dart'; +import '../watch/watch_history.dart'; +import '../watch/watch_tracker.dart'; /// The composition root: builds the app's systems once (in `main`), wires /// them through their constructors and disposes them. No logic and no @@ -51,6 +54,9 @@ class AppGraph { /// The library's matches (L2): local shows to AniList, pins, offsets. final ShowMatcher library; + /// Watch tracking and the device's watch history (D3). + final WatchTracker watch; + AppGraph._({ required this.ports, required this.sfx, @@ -61,6 +67,7 @@ class AppGraph { required this.yourServer, required this.navigation, required this.library, + required this.watch, }); /// The desktop app's graph. Tests pass their own [ports], [identity] @@ -72,12 +79,14 @@ class AppGraph { ServerStarter? serverStarter, JellyfinAccounts? jellyfin, ShowMatcher? library, + WatchHistory? history, }) { final p = ports ?? desktopPorts(); + final settings = AppSettings.instance; final sfx = SynthSfx( - volume: () => AppSettings.instance.soundVolume.peek(), + volume: () => settings.soundVolume.peek(), // A nudge is an explicit poke: it ignores its switch (not the volume). - enabled: (k) => k == SfxKind.nudge || !AppSettings.instance.soundsOff.peek().contains(k.name), + enabled: (k) => k == SfxKind.nudge || !settings.soundsOff.peek().contains(k.name), presetFor: soundPresetFor, ); final notifier = Notifier( @@ -85,11 +94,19 @@ class AppGraph { attention: p.shell.requestAttention, system: p.notifications, focusWindow: p.shell.focus, - prefs: () => AppSettings.instance.notifyPrefs, + prefs: () => settings.notifyPrefs, ); final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored()); + // Device-owned: the watch history box. + final watch = WatchTracker(history: history ?? WatchHistory()); final coordinator = SessionCoordinator( - notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin, players: p.players); + notifier: notifier, + connection: connection, + ytDlp: p.links, + jellyfin: jellyfin, + players: p.players, + settings: settings, + watch: watch); // Device-owned: the library box; AnimeFlow's runs when signed in. final matcher = library ?? ShowMatcher( @@ -97,7 +114,9 @@ class AppGraph { aniList: AniListClient(), flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)), ); - coordinator.fileRef = matcher.refForPath; + coordinator + ..fileRef = matcher.refForPath + ..nextFile = LocalNextFinder(matcher: matcher, scan: p.folders.scan).nextAfter; final forms = ConnectStore(); final yourServer = YourServer( forms: forms, @@ -137,12 +156,14 @@ class AppGraph { yourServer: yourServer, navigation: navigation, library: matcher, + watch: watch, ); } Future dispose() async { navigation.dispose(); await coordinator.dispose(); + await watch.dispose(); await yourServer.stop(); await sfx.dispose(); } diff --git a/apps/desktop/lib/src/controls/minimal/minimal_controls.dart b/apps/desktop/lib/src/controls/minimal/minimal_controls.dart index 055e4c3..3688409 100644 --- a/apps/desktop/lib/src/controls/minimal/minimal_controls.dart +++ b/apps/desktop/lib/src/controls/minimal/minimal_controls.dart @@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget { const Expanded( child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()), ), - UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold), + UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold), const SizedBox(width: 4), SignalBuilder( builder: (context) { diff --git a/apps/desktop/lib/src/controls/model/controls_model.dart b/apps/desktop/lib/src/controls/model/controls_model.dart index 66e7076..edd4dc0 100644 --- a/apps/desktop/lib/src/controls/model/controls_model.dart +++ b/apps/desktop/lib/src/controls/model/controls_model.dart @@ -81,13 +81,16 @@ class PlayerControlsModel { /// Everything the layout depends on (not the position: that changes /// four times a second and only the timeline needs it). late final Computed snapshot = computed(() { - final items = queue.items.value; + final queued = queue.items.value.firstOrNull; + final continues = queue.continues.value; final next = coord.nextEpisode.value; return ControlsSnapshot( play: play.value, previousLabel: coord.previousEpisode.value?.label, - nextLabel: items.isNotEmpty ? items.first.title : next?.label, - nextIsUpNext: items.isNotEmpty, + // Up next's first item, else the next episode, else what continues + // this (a file's next episode). + nextLabel: queued?.title ?? next?.label ?? continues?.title, + nextIsUpNext: queued != null, hasChapters: chapters.value.isNotEmpty, hasDuration: duration.value > Duration.zero, alone: coord.alone.value, @@ -137,9 +140,10 @@ class PlayerControlsModel { void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6); - /// Up next's first item, else the next episode. False when neither. + /// What plays next (Up next's first item, else the continuation), else + /// the next episode. False when neither. bool next() { - if (queue.items.peek().isNotEmpty) { + if (queue.head.peek() != null) { queue.skip(); return true; } diff --git a/apps/desktop/lib/src/controls/standard/standard_controls.dart b/apps/desktop/lib/src/controls/standard/standard_controls.dart index 1dadefd..51609aa 100644 --- a/apps/desktop/lib/src/controls/standard/standard_controls.dart +++ b/apps/desktop/lib/src/controls/standard/standard_controls.dart @@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget { return Row( children: [ const Expanded(child: NowPlayingTitle()), - UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold), + UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold), SignalBuilder( builder: (context) { final full = m.fullscreen.value; diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart index 91cd38c..3f00629 100644 --- a/apps/desktop/lib/src/files/ui/files_page.dart +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -9,6 +9,7 @@ import '../../library/show_matcher.dart'; import '../../shell/motion.dart'; import '../../platform/ports/folder_access.dart'; import '../../app/app_graph.dart'; +import '../../watch/watch_history.dart'; import '../../widgets/app_menu.dart'; import '../folder_store.dart'; import '../scanner.dart'; @@ -127,6 +128,7 @@ class _FilesPageState extends State { late final ThumbCache _thumbs = widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); FolderStore get _store => widget.store ?? FolderStore.instance; + WatchHistory get _history => AppScope.of(context).watch.history; /// Null: Recent; [libraryPlace]: every folder. String? _open = _lastOpen; @@ -325,8 +327,15 @@ class _FilesPageState extends State { thumbs: _thumbs, ); - /// The last episode of [show] played here (Recent), by key. + /// The last episode of [show] watched here (the watch history: the + /// newest one watched to the end), else the last one opened (Recent), by + /// key. String? _lastPlayed(_Library lib, LocalShow show) { + for (final (key, entry) in _history.entries) { + if (!entry.played || !key.value.startsWith('file:')) continue; + final e = lib.byPath[key.value.substring(5)]; + if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key; + } for (final p in _store.recent.value) { final e = lib.byPath[p]; if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key; @@ -342,6 +351,7 @@ class _FilesPageState extends State { final folders = _store.folders.value; final recent = _store.recent.value; _matcher.revision.value; + _history.revision.value; final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace; final showKey = open == null ? null : _openShows[open]; final main = fadeThrough( diff --git a/apps/desktop/lib/src/jellyfin/progress.dart b/apps/desktop/lib/src/jellyfin/progress.dart index 771cc33..6593202 100644 --- a/apps/desktop/lib/src/jellyfin/progress.dart +++ b/apps/desktop/lib/src/jellyfin/progress.dart @@ -26,13 +26,14 @@ class PlayerSnapshot { /// (the session shows one quiet toast) and never touches playback. A 401 /// calls [onUnauthorized] and ends reporting. /// -/// Watched or not is [WatchSession]'s rule, not Jellyfin's position -/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item -/// played before ([playedBefore]) stays played (re-marked after Playing, -/// Progress without a position, Stopped without one, then marked played -/// again); otherwise Progress leaves the position out past -/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on -/// its own, and Stopped carries what the rule says. +/// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's +/// position percentages (see ARCHITECTURE.md "Watch tracking"): the +/// session's watch tracker feeds [watch] and this reporter only reads its +/// verdict. An item played before ([playedBefore]) stays played +/// (re-marked after Playing, Progress without a position, Stopped without +/// one, then marked played again); otherwise Progress leaves the position +/// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it +/// played on its own, and Stopped carries what the rule says. class JellyfinProgressReporter { final JellyfinClient client; final String itemId; @@ -43,7 +44,8 @@ class JellyfinProgressReporter { final void Function(Object error)? onError; final VoidCallback? onUnauthorized; - /// What this viewer watched of the item while it was open here. + /// What this viewer watched of the item while it was open here (fed by + /// the watch tracker, not by this reporter). final WatchSession watch; JellyfinProgressReporter({ @@ -57,12 +59,14 @@ class JellyfinProgressReporter { this.onError, this.onUnauthorized, this.onStopped, + WatchSession? watch, DateTime Function()? clock, }) : _clock = clock ?? DateTime.now, _keepPlayed = playedBefore, _runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond, - watch = WatchSession( - playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond); + watch = (watch ?? WatchSession()) + ..playedBefore = playedBefore + ..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond; /// Past this share of the runtime Progress carries no position: Jellyfin /// marks an item played past its "max resume" share (90 % by default). @@ -126,7 +130,6 @@ class JellyfinProgressReporter { if (_playing || _dead) return; _playing = true; _mark(s); - watch.sample(s.position, playing: !s.paused, at: _clock()); _send('Playing', () => client.reportPlaybackStart(reportFor(s))); if (_keepPlayed) _send('played', () => client.markPlayed(itemId)); } @@ -136,7 +139,6 @@ class JellyfinProgressReporter { if (!_playing || _dead) return; final last = _last, lastAt = _lastAt, sentAt = _lastSentAt; final now = _clock(); - watch.sample(s.position, playing: !s.paused, at: now); var due = force || last == null || lastAt == null || sentAt == null; if (!due) { final elapsed = now.difference(lastAt).inMilliseconds / 1000; @@ -163,11 +165,10 @@ class JellyfinProgressReporter { Future stop(PlayerSnapshot s) { if (!_playing || _dead) return _chain; _playing = false; - watch.sample(s.position, playing: !s.paused, at: _clock()); final v = _keepPlayed ? const WatchVerdict(WatchOutcome.keepPlayed) : watch.verdict(runtime: _runtime, endingStart: _endingStart); - debugPrint('jellyfin $itemId: $v (covered ${watch.covered.round()} s of ${_runtime.round()} s)'); + debugPrint('jellyfin $itemId: $v (${watch.credited.round()} s In of ${_runtime.round()} s)'); final int? ticks; switch (v.outcome) { case WatchOutcome.played || WatchOutcome.keepPlayed: diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart index 39680d6..ab2a727 100644 --- a/apps/desktop/lib/src/jellyfin/session_media.dart +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:sync_relay_core/core.dart'; import '../screens/home/picked_media.dart'; -import '../session/party.dart' show isPresent; import 'accounts.dart'; import 'format.dart'; import '../library/media_refs.dart'; @@ -341,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending 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`, present: `in` or joining). 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 (isPresent(m) && 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/library/local_next.dart b/apps/desktop/lib/src/library/local_next.dart new file mode 100644 index 0000000..4424acb --- /dev/null +++ b/apps/desktop/lib/src/library/local_next.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; + +import '../files/scanner.dart' show FolderScan; +import 'show_matcher.dart'; + +// The next episode of a local show (the library's `nextAfter`, numbers +// skipped on the way included): what continues a file this app put up +// (see ARCHITECTURE.md "What's next"). + +/// [path]'s next episode: from the library the Files page saw this run +/// ([ShowMatcher.showAt]), else from the file's own folder read now (one +/// [scan], grouped by `groupLocal` with that folder's name as a hint). Null when there is none (the last +/// episode, not an episode, an unreadable folder). +class LocalNextFinder { + final ShowMatcher matcher; + final Future Function(String dir) scan; + LocalNextFinder({required this.matcher, required this.scan}); + + Future nextAfter(String path) async { + var show = matcher.showAt(path); + if (show == null) { + final dir = File(path).parent.path; + final found = await scan(dir); + // The folder itself is a hint (a bare `01.mkv` in `Bleach`). + final lib = groupLocal(found.videos, roots: [Directory(dir).parent.path]); + show = lib.shows.where((s) => s.files.any((e) => e.path == path)).firstOrNull; + } + final at = show?.files.where((e) => e.path == path).firstOrNull; + if (show == null || at == null || at.kind != LocalKind.episode) return null; + return show.nextAfter(at.key); + } +} diff --git a/apps/desktop/lib/src/library/show_matcher.dart b/apps/desktop/lib/src/library/show_matcher.dart index 6f8a040..e4c6937 100644 --- a/apps/desktop/lib/src/library/show_matcher.dart +++ b/apps/desktop/lib/src/library/show_matcher.dart @@ -129,6 +129,7 @@ class ShowMatcher { // Files seen in scans this run, for refs of files put up. final _byPath = {}; + final _showByPath = {}; final _entries = {}; final _flows = {}; final _jobs = Function()>{}; @@ -152,13 +153,22 @@ class ShowMatcher { // ------------------------------------------------------------- reads - /// Remembers [library]'s files, so [refForPath] knows their show. + /// Remembers [library]'s files, so [refForPath] and [showAt] know + /// their show. void remember(LocalLibrary library) { for (final e in library.files) { _byPath[e.path] = e; } + for (final s in library.shows) { + for (final e in s.files) { + _showByPath[e.path] = s; + } + } } + /// The show the file at [path] belongs to, as seen in a scan this run. + LocalShow? showAt(String path) => _showByPath[path]; + AniListEntry? entry(int id) { final known = _entries[id]; if (known != null) return known; diff --git a/apps/desktop/lib/src/queue/queue_controller.dart b/apps/desktop/lib/src/queue/queue_controller.dart index c962441..19266d8 100644 --- a/apps/desktop/lib/src/queue/queue_controller.dart +++ b/apps/desktop/lib/src/queue/queue_controller.dart @@ -16,23 +16,27 @@ import '../screens/home/picked_media.dart'; import '../session/app_events.dart' show AddedToUpNext; import '../session/media_art.dart'; import '../session/session_coordinator.dart'; -import '../state/settings.dart'; import '../sync/avatar_cache.dart'; -import 'queue_logic.dart'; -// Up next on this client (see ARCHITECTURE.md "Up next"): the server's -// queue as signals, adding to it (probed, with a picture), the end-of-media -// countdown for its first item, putting an item up when this app is the -// one that can, and keeping that item ready. +// What's next on this app (see ARCHITECTURE.md "What's next" and "Up +// next"): the server's queue as signals (Up next and its continuation), +// adding to it (probed, with a picture), the continuation of what this +// app put up (the next Jellyfin episode, the next file of a local show), +// the server's countdown as the end card shows it, putting an item up +// when the server asks this app (`queuePlay`), and keeping the item that +// plays next ready (the media pipeline prepares it). -/// The end-of-media card for the first Up next item. [secondsLeft] null: -/// no countdown here (auto-advance off, cancelled, or another app puts -/// it up), the card offers Play now only. +/// The end-of-media card for the first item (Up next's, else the +/// continuation). [secondsLeft]: the server's countdown (`state.advance`); +/// null when none runs (cancelled, or nothing counts down), then the card +/// offers Play now only. [waitingFor]: the countdown ran out and the one +/// who puts the item up is not here (its name). class UpNextCountdown { final int mediaId; final QueueItem item; final int? secondsLeft; - const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft}); + final String? waitingFor; + const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft, this.waitingFor}); } /// The first item's readiness on this app. @@ -46,21 +50,33 @@ class QueueController { late final PictureCache thumbs = PictureCache(coord.connection.client, route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]); - /// Up next, in order (empty when nothing is queued or not in a server). - late final Computed> items = - computed(() => coord.serverState.value?.queue ?? const []); + /// Up next, in order, without the continuation (empty when nothing is + /// queued or not in a server). + late final Computed> items = computed( + () => [for (final q in coord.serverState.value?.queue ?? const []) if (!q.auto) q]); + /// The continuation of what plays (`auto`: the next episode), shared by + /// everyone; null when none. + late final Computed continues = + computed(() => coord.serverState.value?.queue.where((q) => q.auto).firstOrNull); + + /// What plays next: Up next's first item, else the continuation. + late final Computed head = computed(() => coord.serverState.value?.queue.firstOrNull); + + /// The end card while the media has ended with something next (see + /// [UpNextCountdown]). final countdown = signal(null); /// The first item's id and how ready it is here (null: nothing to get /// ready on this app). final warm = signal<({String id, WarmState state})?>(null); - /// The natural continuation of what plays: the next episodes of the - /// Jellyfin series (not queued; shown greyed under Up next). + /// The next episodes of the Jellyfin series playing, on an app that + /// plays it with its own account (the first one is what continues it; + /// the rest show greyed under "Continues with", "+" adds one). final continuation = signal>(const []); - /// The mediaId whose continuation was cleared here. + /// The mediaId whose continuation was cleared (by anyone: `cancel`). final _hiddenFor = signal(-1); /// The queue popover is open: the Up next button holds the controls up. @@ -70,10 +86,12 @@ class QueueController { // here, a file here). Never leaves this app. final Map _local = {}; final Map _performing = {}; - final Set _skipped = {}; - int _cancelledFor = -1; Timer? _timer; bool _attached = false; + // The mediaId whose continuation this app added (or is adding). + int _continuedFor = -1; + // A restarted server waits for what played (that mediaId) to come back. + int _restoreFrom = -1; String _continuationFor = ''; final _random = Random.secure(); @@ -83,15 +101,26 @@ class QueueController { if (_attached) return; _attached = true; coord.connection.states.listen(_onState); + // `cancel` from anyone: no continuation for that media any more. + coord.connection.feeds.listen((f) { + final id = coord.serverState.peek()?.mediaId; + if (f.action == 'advance-cancel' && id != null) _hiddenFor.value = id; + }); effect(() { final next = coord.nextEpisode.value; final plan = coord.jellyfinPlan.value; unawaited(_loadContinuation(next, plan)); }); - // The continuation and the state decide what to get ready; the media - // playing having started (or sitting out) lets the preload go. + // What continues this media becomes known (the next episode found, a + // file of ours up): add it. + effect(() { + coord.nextEpisode.value; + coord.ownFile.value; + untracked(() => _continue(coord.serverState.peek())); + }); + // The state decides what to get ready; the media playing having + // started (or sitting out) lets the preload go. effect(() { - shownContinuation.value; coord.settledMediaId.value; coord.selfAway.value; final r = coord.serverState.value; @@ -319,62 +348,54 @@ class QueueController { } } - /// Plays the first Up next item now. + /// Plays what comes next now (Up next's first item, else the + /// continuation). void skip() { - final first = items.peek().firstOrNull; + final first = head.peek(); if (first != null) playNow(first.id); } - /// Cancel on the end-of-media card: no countdown here for this media. + /// Cancel on the end card: the countdown stops for everyone, and the + /// continuation goes (`cancel`). void cancelCountdown() { - final c = countdown.peek(); - _timer?.cancel(); - _timer = null; - if (c == null) return; - _cancelledFor = c.mediaId; - countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item); + coord.markInput(); + coord.connection.sendQueue(const QueueIntent.cancel()); } - /// "Clear": hides the continuation of what plays now (and its next - /// episode does not come up by itself). + /// "Clear" under "Continues with": the continuation of what plays goes + /// for everyone (`cancel`) and does not come back for this media. void clearContinuation() { final id = coord.serverState.peek()?.mediaId; if (id != null) _hiddenFor.value = id; - if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance(); + cancelCountdown(); } - /// The continuation applies to [mediaId]: nothing is queued and it was - /// not cleared. - bool continuationShown(int mediaId) => - (coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId; - - /// [continuation] unless cleared for the media playing. + /// The further episodes of [continuation] (after the one that continues + /// what plays), unless it was cleared for the media playing. late final Computed> shownContinuation = computed(() { final id = coord.serverState.value?.mediaId; - return id != null && _hiddenFor.value == id ? const [] : continuation.value; + if (id == null || _hiddenFor.value == id) return const []; + final auto = continues.value?.media.jellyfin?.itemId; + return [for (final e in continuation.value) if (e.item.id != auto) e]; }); // --------------------------------------------------------- per state void _onState(StateBroadcast r) { _lastState = r; + // What played came back after a restart under a new mediaId: a + // continuation cleared for it stays cleared. + if (_restoreFrom != -1 && r.media != null && _hiddenFor.peek() == _restoreFrom) _hiddenFor.value = r.mediaId; _prune(r); - final queue = r.queue; + // The server asks for an item (a countdown ran out, Play now from + // another app, nothing up yet): the app that puts it up does. final play = r.queuePlay; if (play != null) { - final item = queue.where((q) => q.id == play).firstOrNull; - if (item != null) _bringUp(item, r); - } - final finished = r.transport == 'finished'; - if (queue.isNotEmpty && r.media == null) { - // Nothing up yet: the first item goes up at once. - _bringUp(queue.first, r); - } else if (finished && queue.isNotEmpty) { - _atEnd(r); - } else { - if (countdown.peek() != null) _stopCountdown(); - if (!finished) _cancelledFor = -1; + final item = r.queue.where((q) => q.id == play).firstOrNull; + if (item != null && queuePerformer(item, r.members).id == _me) _perform(item); } + _syncCountdown(r); + _continue(r); _warm(r); } @@ -385,75 +406,133 @@ class QueueController { final now = DateTime.now(); _performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1)); final keys = {for (final q in r.queue) q.key}; - _local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1)); - final ids = {for (final q in r.queue) q.id}; - _skipped.removeWhere((id) => !ids.contains(id)); + // Kept while a restarted server waits for what played: Up next comes + // back with it. + if (_restoreFrom == -1) { + _local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1)); + } } - /// [item] should go up now: by this app when it is the one that can; - /// nobody can: the lowest member takes it off with the reason. - void _bringUp(QueueItem item, StateBroadcast r) { - final perf = queuePerformer(item, r.members); - if (perf.id == null) { - final why = perf.why; - if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) { - coord.connection.sendQueue(QueueIntent.remove(item.id, why: why)); - } - return; - } - if (perf.id == _me) _perform(item); - } - - /// The media finished with Up next waiting: the card, and with - /// auto-advance on the countdown, for its first item. - void _atEnd(StateBroadcast r) { - // Up next goes before the next episode, even one already counting. - if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance(); - final head = r.queue.first; - final perf = queuePerformer(head, r.members); - if (perf.id == null && perf.why != null) { - // Taken off by [_bringUp]'s rule; the next state shows the next one. - _bringUp(head, r); - return; - } - final c = countdown.peek(); - if (c != null && c.mediaId == r.mediaId) { - if (c.item.id != head.id || c.item.thumb != head.thumb) { - countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft); - } - return; - } - final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId; - countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null); - _timer?.cancel(); - _timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null; - } - - void _tick() { - final c = countdown.peek(); - final r = coord.serverState.peek(); - final left = c?.secondsLeft; - if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') { - _stopCountdown(); - return; - } - if (left > 1) { - countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1); - return; - } - _timer?.cancel(); - _timer = null; - _cancelledFor = c.mediaId; - countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item); - // Decided on the latest state: only the app that can puts it up. + /// The end card from the server's state: the media ended with something + /// next; the seconds of `state.advance` move on with a 1 s timer. + void _syncCountdown(StateBroadcast r) { final head = r.queue.firstOrNull; - if (head != null && queuePerformer(head, r.members).id == _me) _perform(head); + if (r.transport != 'finished' || head == null || r.media == null) { + _timer?.cancel(); + _timer = null; + if (countdown.peek() != null) countdown.value = null; + return; + } + final a = r.advance != null && r.advance!.id == head.id ? r.advance : null; + int? left; + String? waiting; + if (a != null) { + left = advanceSecondsLeft(a, coord.connection.serverNow()); + if (left == 0 && queuePerformer(head, r.members).id == null) { + waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName; + } + } + countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting); + if (a == null) { + _timer?.cancel(); + _timer = null; + } else { + _timer ??= Timer.periodic(const Duration(seconds: 1), (_) { + final now = coord.serverState.peek(); + if (now != null) _syncCountdown(now); + }); + } } - void _stopCountdown() { - _timer?.cancel(); - _timer = null; - countdown.value = null; + // ------------------------------------------------------------ restart + + /// The server restarted without media; what played ([mediaId]) is being + /// offered back (-1: that is over). + void restoreFrom(int mediaId) => _restoreFrom = mediaId; + + /// What played is back after a server restart: Up next as [before] had + /// it comes back too. The app whose offer won ([winner]) adds the items + /// anyone can put up (links everyone opens, Jellyfin items); every app + /// its own links passed on and files (only it has them; [self] is its + /// member id then). A server-fetched link's address stayed with the old + /// server, so it is gone; the continuation comes back as usual. + void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) { + for (final q in before.queue) { + if (q.auto) continue; + final ok = switch (q.how) { + QueueHow.direct || QueueHow.jellyfin => winner, + QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key), + QueueHow.server => false, + }; + if (!ok) continue; + final local = _local[q.key]; + if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now()); + final thumb = thumbs.bytesFor(q.thumb); + coord.connection.sendQueue(QueueIntent.add( + QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key), + thumb: thumb == null ? '' : base64Encode(thumb))); + } + _restoreFrom = -1; + } + + // ------------------------------------------------------- continuation + + /// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue") + /// when this app may ([mayAddContinuation]: it put it up, or that + /// member is not in and this is the first who is), knows what comes + /// next, and it is not there or cancelled yet; with the setting on. A + /// Jellyfin episode played with this app's account continues with the + /// series' next one, a file this app put up with the next episode of its + /// show in the library (numbers skipped on the way said in its line). + void _continue(StateBroadcast? r) { + if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return; + final id = r.mediaId; + if (_continuedFor == id || _hiddenFor.peek() == id) return; + if (r.queue.any((q) => q.auto && q.forMedia == id)) { + _continuedFor = id; + return; + } + final swapBy = r.media?['swapBy']; + if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return; + final ref = refOfStateMedia(r.media); + final next = coord.nextEpisode.peek(); + if (ref != null) { + if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return; + _continuedFor = id; + unawaited(_addContinuation(id, next.pick)); + return; + } + final own = coord.ownFile.peek(); + final finder = coord.nextFile; + if (own == null || own.$1 != id || finder == null) return; + _continuedFor = id; + unawaited(() async { + try { + final n = await finder(own.$2); + if (n == null) return; + final gap = n.skipped; + await _addContinuation(id, FileMedia(n.episode.path), + note: gap == null ? null : 'skips ${gap.label}'); + } catch (e) { + debugPrint('queue: what continues ${own.$2}: $e'); + } + }()); + } + + Future _addContinuation(int mediaId, PickedMedia pick, {String? note}) async { + try { + final b = await _build(pick); + if (b is! ({QueueItem item, Uint8List? thumb})) return; + final r = coord.serverState.peek(); + if (r == null || r.mediaId != mediaId || _hiddenFor.peek() == mediaId) return; + final sub = [b.item.sub, ?note].where((s) => s.isNotEmpty).join(' · '); + final item = b.item.copyWith(auto: true, forMedia: mediaId, sub: sub); + if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now()); + final thumb = b.thumb; + coord.connection.sendQueue(QueueIntent.add(item, thumb: thumb == null ? '' : base64Encode(thumb))); + } catch (e) { + debugPrint('queue: adding what continues media $mediaId: $e'); + } } /// Puts [item] up from here (once per item while it is in flight). @@ -530,9 +609,8 @@ class QueueController { bool _ownAccount(String serverId) => coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired; - /// What this app gets ready: Up next's first item, else (Up next empty) - /// the first continuation episode (only here when this app plays the - /// series with its own account). Every app does, each its own copy (see + /// What this app gets ready: the first item of the queue (Up next's, + /// else the continuation). Every app does, each its own copy (see /// [_WarmTarget]); while sitting out only the probe of a link this app /// puts up (its swap reads it). Null: nothing. _WarmTarget? _warmTarget(StateBroadcast? r) { @@ -579,9 +657,7 @@ class QueueController { return performer ? _WarmTarget(head.id) : null; } } - final next = shownContinuation.peek().firstOrNull; - if (next == null) return null; - return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin); + return null; } /// Keeps the one item that plays next ready on this app, so a skip or diff --git a/apps/desktop/lib/src/queue/queue_logic.dart b/apps/desktop/lib/src/queue/queue_logic.dart index 940c111..48964e0 100644 --- a/apps/desktop/lib/src/queue/queue_logic.dart +++ b/apps/desktop/lib/src/queue/queue_logic.dart @@ -1,66 +1,15 @@ import 'package:sync_relay_core/core.dart'; -import '../jellyfin/session_media.dart' show compareMemberIds; -import '../session/party.dart' show isPresent; - -// Who puts an Up next item up. Pure: only what every client sees in -// `state` decides, so all clients agree without a wire message (like the -// Jellyfin auto-advance swapper). - -/// Who puts [item] up: [id] a member id; null with [why] when nobody here -/// can (the item is skipped), null without [why] when nobody is around -/// right now (nothing happens). -typedef QueuePerformer = ({String? id, String? why}); - -/// - A link passed on by its adder, or the adder's file: only the adder -/// (their app has the file, the cookies). Gone (disconnected, in its -/// grace): it waits for them; left for good: skipped. -/// - Jellyfin: the adder when it plays that server with its own account, -/// else the lowest member id that does (`jf[serverId] == true`, -/// present: `in` or joining). Nobody: skipped. -/// - A link everyone opens, or one the server fetches: the adder when -/// present, else the lowest present member id. -QueuePerformer queuePerformer(QueueItem item, List members) { - MemberView? adder; - for (final m in members) { - if (m.id == item.by) adder = m; - } - bool active(MemberView m) => isPresent(m); - if (item.how.adderOnly) { - if (adder == null) return (id: null, why: '${_name(item)} left'); - if (adder.presence == Presence.gone) return (id: null, why: null); - return (id: adder.id, why: null); - } - final ref = item.media.jellyfin; - if (item.how == QueueHow.jellyfin && ref != null) { - bool can(MemberView m) => active(m) && m.jf[ref.serverId] == true; - if (adder != null && can(adder)) return (id: adder.id, why: null); - final id = autoAdvanceLowest(members.where(can)); - return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null); - } - if (adder != null && active(adder)) return (id: adder.id, why: null); - return (id: autoAdvanceLowest(members.where(active)), why: null); -} - -String _name(QueueItem item) => item.byName.isEmpty ? 'who added it' : item.byName; - -/// The lowest member id of [members] ("m2" before "m10"), or null. -String? autoAdvanceLowest(Iterable members) { - final ids = [for (final m in members) m.id]..sort(compareMemberIds); - return ids.isEmpty ? null : ids.first; -} - -/// Who takes an item nobody can put up off the queue (`remove` with why): -/// the lowest member id that is connected (not gone). -String? queueJanitor(List members) => - autoAdvanceLowest(members.where((m) => m.presence != Presence.gone)); +// Up next's words for an item. Who puts an item up is core's +// `queuePerformer` (whats_next.dart), read the same by the server and +// every app. /// How [item] plays, in a few words for its row: "through Foxyz" (you: /// "through you"), "server fetches", "everyone fetches", "Jellyfin", /// "Foxyz's file". String queueHowLabel(QueueItem item, {required String selfId}) { final mine = item.by == selfId && selfId.isNotEmpty; - final who = mine ? 'you' : _name(item); + final who = mine ? 'you' : (item.byName.isEmpty ? 'who added it' : item.byName); return switch (item.how) { QueueHow.through => 'through $who', QueueHow.file => mine ? 'your file' : "$who's file", diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart index c12261b..c603011 100644 --- a/apps/desktop/lib/src/queue/queue_ui.dart +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -12,7 +12,6 @@ import '../controls/minimal/style.dart'; import '../jellyfin/images.dart'; import '../jellyfin/session_media.dart' show EpisodeLink; import '../screens/home/picked_media.dart'; -import '../session/session_coordinator.dart' show autoAdvanceSecs; import '../shell/avatar.dart'; import '../shell/handoff.dart' show handoffHolds; import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview; @@ -38,17 +37,14 @@ enum UpNextLook { standard, minimal } /// The next item's picture, "UP NEXT" over its title, a +n count and ⏭ /// (play it now) in the player's top bar, one flat control among its /// buttons; the title opens the queue, resting on it shows a preview card. -/// Without Up next, the continuation ("NEXT · S1 E8" over its name, ⏭ +/// Without Up next, the continuation ("NEXT · S1E8" over its name, ⏭ /// plays it). Nothing when both are empty. class UpNextButton extends StatefulWidget { final QueueController queue; - /// Plays the continuation's first episode (the next episode). - final VoidCallback onPlayNextEpisode; final UpNextLook look; /// Held while the preview card is up, so the controls stay up with it. final ControlsHold? hold; - const UpNextButton( - {super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold}); + const UpNextButton({super.key, required this.queue, required this.look, this.hold}); @override State createState() => _UpNextButtonState(); @@ -143,9 +139,9 @@ class _UpNextButtonState extends State { final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0; return SignalBuilder(builder: (context) { final items = _q.items.value; - final cont = _q.shownContinuation.value; + final auto = _q.continues.value; final open = _q.popoverOpen.value; - if (items.isEmpty && cont.isEmpty && !open) { + if (items.isEmpty && auto == null && !open) { // Gone with the preview up: let go of the controls. if (_preview.isShowing) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -155,21 +151,15 @@ class _UpNextButtonState extends State { return const SizedBox.shrink(); } final queued = items.isNotEmpty; - final item = items.firstOrNull; - final episode = cont.firstOrNull; + final item = items.firstOrNull ?? auto; final more = items.length - 1; final String kicker; final String title; final Widget thumb; if (item != null) { - kicker = 'UP NEXT'; + kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}'); title = item.title; thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius); - } else if (episode != null) { - final n = episodeNumbers(episode.item); - kicker = n == null ? 'NEXT' : 'NEXT · $n'; - title = episode.item.name; - thumb = _EpisodeThumb(queue: _q, episode: episode, width: thumbWidth, radius: thumbRadius); } else { kicker = 'UP NEXT'; title = 'Nothing yet'; @@ -246,10 +236,8 @@ class _UpNextButtonState extends State { ), ), ); - final playTip = queued - ? 'Play ${item!.title} now ($playNextKeys)' - : 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)'; - final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null); + final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)'; + final VoidCallback? play = item == null ? null : _q.skip; final playButton = _minimal ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) : Tooltip( @@ -337,58 +325,33 @@ class UpNextPreview extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(_pad), child: SignalBuilder(builder: (context) { - final item = queue.items.value.firstOrNull; - final episode = queue.shownContinuation.value.firstOrNull; + final queued = queue.items.value.firstOrNull; + final item = queued ?? queue.continues.value; final warm = queue.warm.value; const picture = _width - 2 * _pad; - if (item != null) { - final r = queue.coord.serverState.value; - final selfId = queue.coord.connection.memberId; - final more = queue.items.value.length - 1; - final ready = warm?.id == item.id ? warm!.state : null; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - QueueThumb(queue: queue, item: item, width: picture, radius: 8), - const SizedBox(height: 10), - Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker), - const SizedBox(height: 3), - Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), - if (item.sub.isNotEmpty) - Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), - const SizedBox(height: 6), - _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true), - _footer(ready, ready == null ? performerNote(queue, item, r) : null), - ], - ); - } - if (episode == null) return const SizedBox.shrink(); - final n = episodeNumbers(episode.item); - final runtime = episode.pick.runtime; - final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())] - .where((s) => s.isNotEmpty) - .join(' · '); - final overview = episode.item.overview?.trim() ?? ''; - final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null; + if (item == null) return const SizedBox.shrink(); + final r = queue.coord.serverState.value; + final selfId = queue.coord.connection.memberId; + final more = queue.items.value.length - 1; + final ready = warm?.id == item.id ? warm!.state : null; + final kicker = queued == null + ? 'CONTINUES WITH' + : more > 0 + ? 'UP NEXT · $more MORE AFTER IT' + : 'UP NEXT'; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8), + QueueThumb(queue: queue, item: item, width: picture, radius: 8), const SizedBox(height: 10), - Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker), + Text(kicker, style: _kicker), const SizedBox(height: 3), - Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), - if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), - if (overview.isNotEmpty) ...[ - const SizedBox(height: 6), - Text(overview, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)), - ], - _footer(ready, null), + Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), + if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), + const SizedBox(height: 6), + _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true), + _footer(ready, ready == null ? performerNote(queue, item, r) : null), ], ); }), @@ -481,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget { final QueueController queue; final EpisodeLink episode; final double width; - final double radius; - const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6}); + const _EpisodeThumb({required this.queue, required this.episode, required this.width}); @override Widget build(BuildContext context) { @@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget { final client = queue.coord.jellyfin.clientFor(e.pick.serverId); final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); return ClipRRect( - borderRadius: BorderRadius.circular(radius), + borderRadius: BorderRadius.circular(6), child: SizedBox( width: width, height: width * 9 / 16, @@ -838,6 +800,7 @@ class _QueuePanelState extends State { ), child: SignalBuilder(builder: (context) { final items = _ordered(_q.items.value); + final auto = _q.continues.value; final cont = _q.shownContinuation.value; final warm = _q.warm.value; final r = _q.coord.serverState.value; @@ -848,7 +811,7 @@ class _QueuePanelState extends State { children: [ _header(items.length), Flexible( - child: items.isEmpty && cont.isEmpty && _adding.isEmpty + child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty ? _empty() : ListView( shrinkWrap: true, @@ -900,7 +863,9 @@ class _QueuePanelState extends State { adding: a, onDismiss: () => setState(() => _adding.remove(a)), ), - if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty && _adding.isEmpty), + if (auto != null || cont.isNotEmpty) + ..._continuation(auto, cont, items.isEmpty && _adding.isEmpty, + warm: auto != null && warm?.id == auto.id ? warm!.state : null), ], ), ), @@ -956,7 +921,9 @@ class _QueuePanelState extends State { ), ); - List _continuation(List cont, bool alone) => [ + /// "Continues with": the continuation everyone shares ([auto], Clear + /// drops it for everyone), then the series' further episodes known here. + List _continuation(QueueItem? auto, List cont, bool alone, {WarmState? warm}) => [ Padding( padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0), child: Row( @@ -964,15 +931,17 @@ class _QueuePanelState extends State { const Text('CONTINUES WITH', style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)), const Spacer(), - TextButton( - key: const ValueKey('queue-clear-continuation'), - style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), - onPressed: _q.clearContinuation, - child: const Text('Clear', style: TextStyle(fontSize: 12)), - ), + if (auto != null) + TextButton( + key: const ValueKey('queue-clear-continuation'), + style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), + onPressed: _q.clearContinuation, + child: const Text('Clear', style: TextStyle(fontSize: 12)), + ), ], ), ), + if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm), for (final e in cont) _ContinuationRow(episode: e, queue: _q), ]; @@ -1479,6 +1448,76 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin } } +/// The continuation's row (the `auto` item): greyed like what is not +/// queued yet, its picture with how ready it is here, ▶ plays it now. +class _ContinuesRow extends StatefulWidget { + final QueueItem item; + final QueueController queue; + final WarmState? warm; + const _ContinuesRow({required this.item, required this.queue, required this.warm}); + + @override + State<_ContinuesRow> createState() => _ContinuesRowState(); +} + +class _ContinuesRowState extends State<_ContinuesRow> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final item = widget.item; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hover ? 0.95 : 0.7, + child: Container( + key: ValueKey('queue-continues-${item.id}'), + height: queueRowHeight, + padding: const EdgeInsets.only(left: _lead, right: 8), + decoration: BoxDecoration( + color: _hover ? AppMenuStyle.hover : Colors.transparent, + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + ), + child: Row( + children: [ + Stack( + children: [ + QueueThumb(queue: widget.queue, item: item, width: _thumb), + if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle), + Text( + [item.sub, queueClock(item.duration)].where((s) => s.isNotEmpty).join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _subStyle, + ), + ], + ), + ), + _RoundIcon( + key: ValueKey('queue-play-${item.id}'), + icon: Icons.play_arrow_rounded, + tooltip: 'Play now', + onPressed: () => widget.queue.playNow(item.id), + ), + ], + ), + ), + ), + ); + } +} + class _ContinuationRow extends StatefulWidget { final EpisodeLink episode; final QueueController queue; @@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> { // -------------------------------------------------------------- end card -/// When the media ended with Up next waiting: its picture, title and who -/// added it, the countdown with Cancel, and Play now. +/// When the media ended with something next (Up next's first item, else +/// the continuation): its picture, title and who added it, the server's +/// countdown with Cancel (for everyone), and Play now. class UpNextCard extends StatelessWidget { final QueueController queue; final UpNextCountdown countdown; @@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget { final left = c.secondsLeft; final r = queue.coord.serverState.value; final selfId = queue.coord.connection.memberId; - final waiting = left == null ? performerNote(queue, item, r) : null; + final waitingFor = c.waitingFor; + final waiting = waitingFor != null + ? 'Waiting for $waitingFor' + : left == null || left == 0 + ? performerNote(queue, item, r) + : null; + final what = item.auto ? 'Next' : 'Up next'; return AppGlass( key: const ValueKey('up-next-card'), child: SizedBox( @@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(left == null ? 'Up next' : 'Up next in $left s', + Text(left == null || left == 0 ? what : '$what in $left s', style: const TextStyle(color: Colors.white60, fontSize: 12)), const SizedBox(height: 2), Text(item.title, @@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(2), child: TweenAnimationBuilder( - tween: Tween(end: left / autoAdvanceSecs), + tween: Tween(end: left / RelayConstants.advanceSecs), duration: const Duration(seconds: 1), builder: (context, v, _) => LinearProgressIndicator( value: v, diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 00f27b8..240282b 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -220,8 +220,8 @@ class AutoAdvanceSetting extends StatelessWidget { key: const ValueKey('auto-advance'), contentPadding: EdgeInsets.zero, title: const Text('Play the next episode automatically'), - subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; ' - 'anyone can cancel it'), + subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one ' + 'continues it after a 10 s countdown everyone sees; anyone can cancel it'), value: s.autoAdvance.value, onChanged: s.setAutoAdvance, ), diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index a92cfaa..bcf830e 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import '../../jellyfin/session_media.dart'; -import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; -import '../../widgets/app_menu.dart' show AppGlass; import 'player_controls.dart' show FloatingPill; // Jellyfin pieces of the session player. Plain widgets fed by the @@ -24,80 +22,6 @@ class NextEpisodePill extends StatelessWidget { ); } -/// 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 c = countdown; - // The same card as Up next's (`UpNextCard`): the app's glass, 400 wide. - return AppGlass( - key: const ValueKey('end-of-episode'), - child: SizedBox( - width: 400, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(c == null ? 'Up next' : 'Next episode in ${c.secondsLeft} s', - style: const TextStyle(color: Colors.white60, fontSize: 12)), - const SizedBox(height: 2), - Text('Next: ${next.label}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)), - if (c != null) ...[ - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: TweenAnimationBuilder( - tween: Tween(end: c.secondsLeft / autoAdvanceSecs), - duration: const Duration(seconds: 1), - builder: (context, v, _) => - LinearProgressIndicator(value: v, minHeight: 3, backgroundColor: const Color(0x1FFFFFFF)), - ), - ), - ], - 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 { diff --git a/apps/desktop/lib/src/session/connection.dart b/apps/desktop/lib/src/session/connection.dart index 2553fb3..5a047e4 100644 --- a/apps/desktop/lib/src/session/connection.dart +++ b/apps/desktop/lib/src/session/connection.dart @@ -391,7 +391,8 @@ class Connection { void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source); void sendForce(String target, bool ready) => client.sendForce(target, ready); void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq); - void sendSwap(HelloMedia media, {String? queueId}) => client.sendSwap(media, queueId: queueId); + void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) => + client.sendSwap(media, queueId: queueId, restore: restore); void sendQueue(QueueIntent intent) => client.sendQueue(intent); void sendReport({ required double position, diff --git a/apps/desktop/lib/src/session/media_pipeline.dart b/apps/desktop/lib/src/session/media_pipeline.dart index 0d033b4..46c0602 100644 --- a/apps/desktop/lib/src/session/media_pipeline.dart +++ b/apps/desktop/lib/src/session/media_pipeline.dart @@ -446,6 +446,37 @@ class MediaPipeline { await playback.stop(); } + /// The same media came back as [r.mediaId] (a restarted server put back + /// what played, `swap.restore`) and the player keeps it open: what this + /// member passes on is served again (its file, its link, the Jellyfin + /// item played with its own account: [ownJellyfin]). + void resumeAs(StateBroadcast r, {({String url, Map headers})? ownJellyfin}) { + renumber(r.mediaId); + settledMediaId.value = r.mediaId; + final source = plan(r); + final name = (r.media?['name'] as String?) ?? ''; + final queueId = stateMediaRelay(r)?.queueId ?? ''; + final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0; + switch (source) { + case OwnFile(:final path): + feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))), + queueId: queueId, name: name, duration: duration); + case OwnLink(:final url, :final origin): + feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe), + queueId: queueId, name: name, duration: duration); + case JellyfinSource(:final ref, :final origin): + if (origin == null || ownJellyfin == null) return; + final media = RelayMedia.single(HttpRelayBytes(Uri.parse(ownJellyfin.url), headers: ownJellyfin.headers)); + if (jellyfinRelayNeeded(r, ref, session.selfId())) { + feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration); + } else { + _jellyfinFeed = (mediaId: r.mediaId, ref: ref, media: media, queueId: queueId, name: name, duration: duration); + } + default: + break; + } + } + /// The media was settled here (opened, failed, nothing to open). void settled(int mediaId) { if (_mediaId == mediaId) settledMediaId.value = mediaId; diff --git a/apps/desktop/lib/src/session/notifier.dart b/apps/desktop/lib/src/session/notifier.dart index 71d9e30..aeba3c0 100644 --- a/apps/desktop/lib/src/session/notifier.dart +++ b/apps/desktop/lib/src/session/notifier.dart @@ -187,6 +187,12 @@ Notice _plain(AppEvent e) => switch (e) { category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor), MemberReturned(:final feed) => Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor), + // Put back after a server restart: news, but not a change. + MediaSwapped(:final feed) when feed.reason == 'restore' => Notice( + category: NoticeCategory.upNext, + log: feed, + toast: '${feed.actor} put back what was playing', + actor: feed.actor), MediaSwapped(:final feed) => Notice( category: NoticeCategory.upNext, log: feed, diff --git a/apps/desktop/lib/src/session/party.dart b/apps/desktop/lib/src/session/party.dart index c3dd292..d81f050 100644 --- a/apps/desktop/lib/src/session/party.dart +++ b/apps/desktop/lib/src/session/party.dart @@ -12,8 +12,8 @@ const presencePolicy = PresencePolicy(); bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter); /// Connected and watching (`in` or `joining`): can do things for the -/// party now (put up an Up next item, the auto-advance swap). -bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining; +/// party now (put up an Up next item; core's [isPresentMember]). +bool isPresent(MemberView m) => isPresentMember(m); /// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]). bool aloneIn(List members, String selfId) => diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 13f571c..8747bc0 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -23,6 +23,7 @@ import '../schedule/anilist.dart'; import '../screens/home/picked_media.dart'; import '../sfx/sfx.dart' show NoopSfxService, SfxService; import '../state/settings.dart'; +import '../watch/watch_tracker.dart'; import '../sync/avatar_cache.dart'; import '../sync/state_diff.dart'; import '../sync/user_seek.dart'; @@ -52,21 +53,6 @@ final _log = createLogger('session'); /// (Jellyfin-style links carry api_key there and names are broadcast). String linkDisplayName(String url) => linkName(url); -/// 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; - /// "Unready when the video changes": a state with a newer [mediaId] than /// the last one this session saw ([lastMediaId], -1 before the first) while /// self is ready (or a fresh member after a reconnect is restoring it). A @@ -100,6 +86,14 @@ class SessionCoordinator { /// What a local file is (`media.ref`, from the library: `ShowMatcher`), /// set by the composition root; null: files go up without one. MediaRef? Function(String path)? fileRef; + /// The next episode of the local show a file belongs to (the library's + /// `nextAfter`, `LocalNextFinder`), set by the composition root; null: + /// files have no continuation. + Future Function(String path)? nextFile; + /// The app's preferences (the composition root's). + final AppSettings settings; + /// Watch tracking: one watch per media, fed every second (D3). + final WatchTracker watch; /// The app's yt-dlp and link probes (tests pass their own). final LinkResolver ytDlp; /// The link to the server (socket, reconnects, clock offset, its @@ -217,8 +211,6 @@ class SessionCoordinator { /// else the last two minutes); the pill shows in it only while the /// controls are up. final nextPromptVisible = signal(false); - /// End-of-episode countdown; null when none runs here. - final autoAdvance = signal(null); /// The skip pill for the current position ([skipOfferAt]: Skip OP, /// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing @@ -284,7 +276,8 @@ class SessionCoordinator { // This client's last swap, until a state shows it landed (a newer // mediaId than [after] with this member as `swapBy`): its art goes up then. // [link]: the web link it came from (a relayed one has no `url`). - ({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt; + // [file]: the local file it came from. + ({HelloMedia media, int after, String? link, String? file, Uint8List? art})? _pendingArt; AniListClient? _aniList; /// This member serves the current media's bytes through the server. Signal get relaying => media.feeder.relaying; @@ -317,8 +310,16 @@ class SessionCoordinator { JellyfinProgressReporter? _reporter; PlayerSnapshot? _lastSnap; bool _jfToastShown = false; - Timer? _advanceTimer; - int _advanceDoneFor = -1; + // The media whose end was recorded as a watch checkpoint. + int _watchEndFor = -1; + // A restarted server came back without media while this app still had + // what played: that state, this member's id in it and the token then, + // until a state has media again (see [_beginRestore]). + ({StateBroadcast before, String self, String oldToken, DateTime at})? _restore; + Timer? _restoreTimer; + static const _restoreGiveUp = Duration(seconds: 30); + // The member id the latest state was seen as (the old one, at a welcome). + String _stateSelf = ''; String _blockedKey = ''; bool _jellyfinListening = false; // The mediaId [playerError] belongs to, and the player's duration when @@ -337,7 +338,9 @@ class SessionCoordinator { JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, LinkResolver? ytDlp, - PlayerHost? players}) { + PlayerHost? players, + AppSettings? settings, + WatchTracker? watch}) { final n = notifier ?? Notifier(sfx: sfx); final links = ytDlp ?? AppYtDlp.instance; return SessionCoordinator._( @@ -347,6 +350,8 @@ class SessionCoordinator { jellyfin: jellyfin ?? JellyfinAccounts.instance, trackPrefs: trackPrefs ?? TrackPrefs(), media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links), + settings: settings ?? AppSettings.instance, + watch: watch ?? WatchTracker(), ); } @@ -356,7 +361,9 @@ class SessionCoordinator { required this.ytDlp, required this.jellyfin, required this.trackPrefs, - required this.media}) { + required this.media, + required this.settings, + required this.watch}) { connection ..onWelcome = _onWelcome ..caps = () => caps; @@ -614,7 +621,10 @@ class SessionCoordinator { /// three viewers readying at once). A state that arrives meanwhile /// replaces any still waiting. void _onState(StateBroadcast r) { + final hold = _restore; + if (hold != null && r.media != null) _landRestore(hold, r); serverState.value = r; + _stateSelf = connection.memberId; if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing); _toastMemberErrors(r); _warnSlowSource(r); @@ -641,6 +651,9 @@ class SessionCoordinator { } Future _applyState(StateBroadcast r) async { + // A restarted server without media yet: the player goes on with what + // played until it is back (or given up). + if (_restore != null && r.media == null) return; _trackSelfReady(r); if (_lastAway) { // A newer media than the one this member serves: that relay ended. @@ -737,7 +750,7 @@ class SessionCoordinator { // A ready restored by a reconnect may not have echoed yet: unready // anyway (it was sent first, so this one lands last). if (unreadyForNewMedia( - enabled: AppSettings.instance.unreadyOnSwap.peek(), + enabled: settings.unreadyOnSwap.peek(), lastMediaId: last, mediaId: r.mediaId, ready: self.ready || _restoringReady)) { @@ -834,19 +847,54 @@ class SessionCoordinator { _sendReport(r); if (!_lastAway) _checkSeekLanded(r); } - // The Jellyfin progress goes on through a drop (the player does). + // The Jellyfin progress and the watch go on through a drop (the + // player does). + if (r != null) _sampleWatch(r, now); if (connection.token.isNotEmpty && r != null && _playerReady && !_lastAway) unawaited(_tickJellyfin(r)); if (r != null && online) _readyAfterJoinBack(r, now); _tickAutoSitOut(r, now, online: online); } + /// One second of the watch of what plays ([WatchTracker]): its position + /// while this player holds it, whether it plays here, whether this + /// viewer is In; its end is recorded once (it may go on after a seek + /// back). + void _sampleWatch(StateBroadcast state, DateTime now) { + // A restarted server waiting for what played to come back: the watch + // goes on with it (the player plays on); a reconnect never ends it. + final hold = _restore; + final waiting = hold != null && state.media == null; + final r = waiting ? hold.before : state; + final holds = _playerReady && !_lastAway && !_opening && _openUrl != null && media.playerMediaId == r.mediaId; + final ending = playback.ending.peek()?.start; + watch.sample(WatchSample( + mediaId: r.mediaId, + media: r.media, + ownFile: _ownFile?.$1 == r.mediaId ? _ownFile!.$2 : null, + ownLink: _ownLink?.$1 == r.mediaId ? _ownLink!.$2 : null, + position: holds ? playback.positionSecs : 0, + playing: holds && (waiting || r.transport == 'playing') && playback.playing, + present: selfMember.peek()?.presence == Presence.watching, + duration: holds ? playback.durationSecs : 0, + endingStart: ending == null ? null : ending.inMilliseconds / 1000, + at: now, + )); + if (waiting) return; + if (r.transport == 'finished' && _watchEndFor != r.mediaId) { + _watchEndFor = r.mediaId; + watch.checkpoint(at: now); + } else if (r.transport != 'finished' && _watchEndFor == r.mediaId) { + _watchEndFor = -1; + } + } + /// Auto sit-out's second (only while online: the state is current). void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) { final step = autoSitOut.tick(now, self: online ? selfMember.peek()?.presence : null, playing: r?.transport == 'playing', alone: r == null || aloneIn(r.members, connection.memberId), - enabled: AppSettings.instance.autoSitOut.peek()); + enabled: settings.autoSitOut.peek()); switch (step) { case AutoAway(:final reason): _log.info('auto sit-out: away (${reason.name})'); @@ -1075,7 +1123,7 @@ class SessionCoordinator { /// [queueId]: the Up next item this puts up (the server takes it off /// the queue); [art]: a picture to send as its art (a queued item's). - void _swap(HelloMedia media, {String? link, String? queueId, Uint8List? art}) { + void _swap(HelloMedia media, {String? link, String? file, String? queueId, Uint8List? art, SwapRestore? restore}) { // A swap sent into a dropped link would be lost without a word. if (!connection.state.peek().online) { notify(const ActionFailed('Reconnecting: put it up again once back')); @@ -1084,12 +1132,13 @@ class SessionCoordinator { // Putting something up means watching it: back first. Not for Up next // coming up by itself: whoever puts it up may be sitting out. final self = selfMember.peek(); - if (self != null && self.presence == Presence.away && queueId == null) { + if (self != null && self.presence == Presence.away && queueId == null && restore == null) { autoSitOut.manual(); connection.sendPresence(const PresenceIntent(state: Presence.watching)); } - _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art); - connection.sendSwap(media, queueId: queueId); + _pendingArt = + (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, file: file, art: art); + connection.sendSwap(media, queueId: queueId, restore: restore); } /// An Up next item put up as queued, for one this app cannot build @@ -1107,12 +1156,20 @@ class SessionCoordinator { _pendingArt = null; final link = pending.link; _ownLink = link == null || link.isEmpty ? null : (r.mediaId, link); + final file = pending.file; + _ownFile = file == null ? null : (r.mediaId, file); + ownFile.value = _ownFile; unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art)); } /// The link this app put up as [StateBroadcast.mediaId] (passed on or /// fetched by the server, so not in the state). (int, String)? _ownLink; + (int, String)? _ownFile; + + /// The local file this app put up, with the mediaId it went up as (null: + /// none); Up next's continuation of a show reads it. + final ownFile = signal<(int, String)?>(null); /// The link of what plays, as this app may hand it out (Copy link, Open /// in browser): one this app put up, else one everyone opens (it is in @@ -1170,12 +1227,12 @@ class SessionCoordinator { /// reason in plain words unless [anyway]. Returns an error text, or null /// when sent. Future swapLink(String url, - {LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async { + {LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId, SwapRestore? restore}) async { markInput(); final media = await mediaForLink(jellyfin, url); if (media.jellyfin != null) { this.media.expectOrigin(JellyfinOrigin(url)); - _swap(media.copyWith(relay: const MediaRelay()), queueId: queueId); + _swap(media.copyWith(relay: const MediaRelay()), queueId: queueId, restore: restore); return null; } LinkProbe? probe; @@ -1192,7 +1249,7 @@ class SessionCoordinator { final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); final what = linkMediaRef(name); if (relay == LinkRelay.direct) { - _swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId); + _swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId, restore: restore); return null; } if (relay == LinkRelay.me) { @@ -1227,7 +1284,7 @@ class SessionCoordinator { } this.media.expectOrigin(LinkOrigin(url, probe, y)); _swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what), - link: url, queueId: queueId); + link: url, queueId: queueId, restore: restore); return null; } // The server reads it with its own yt-dlp; only a page this app knows @@ -1243,7 +1300,8 @@ class SessionCoordinator { url: url, relay: const MediaRelay(fetch: true), ref: what), - queueId: queueId); + queueId: queueId, + restore: restore); return null; } @@ -1257,23 +1315,24 @@ class SessionCoordinator { /// A Jellyfin pick (browser, next / previous episode) goes up as a ref, /// relayed by this member for viewers without an account. - void swapJellyfin(JellyfinMedia media, {String? queueId}) { + void swapJellyfin(JellyfinMedia media, {String? queueId, SwapRestore? restore}) { markInput(); this.media.expectOrigin(const JellyfinOrigin(null)); - _swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId); + _swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId, restore: restore); } /// A file on this computer. The host's app serves it itself; anyone /// else's goes through the server's relay. Returns an error text, or null /// when sent. - Future swapFile(String path, {String? queueId}) async { + Future swapFile(String path, {String? queueId, SwapRestore? restore}) async { markInput(); final what = fileRef?.call(path); final host = hostFile; if (host != null) { final file = await host(path); if (file == null) return 'file not found — check the path'; - _swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), queueId: queueId); + _swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), + file: path, queueId: queueId, restore: restore); return null; } final file = File(path); @@ -1285,7 +1344,9 @@ class SessionCoordinator { duration: 0, relay: const MediaRelay(), ref: what), - queueId: queueId); + file: path, + queueId: queueId, + restore: restore); return null; } @@ -1302,9 +1363,17 @@ class SessionCoordinator { return null; } + /// The next episode now: through Up next when it is the continuation + /// there (its preload and relay carry on), else a swap. void playNextEpisode() { final n = nextEpisode.peek(); - if (n != null) swapJellyfin(n.pick); + if (n == null) return; + final auto = queue.continues.peek(); + if (auto != null && auto.media.jellyfin?.itemId == n.item.id) { + queue.playNow(auto.id); + } else { + swapJellyfin(n.pick); + } } void playPreviousEpisode() { @@ -1312,16 +1381,6 @@ class SessionCoordinator { 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 /// News for the user, from the session or anywhere in the app (see @@ -1342,10 +1401,6 @@ class SessionCoordinator { nextEpisode.value = null; nextPromptVisible.value = false; playback.segments.value = const []; - _advanceTimer?.cancel(); - _advanceTimer = null; - _advanceDoneFor = -1; - autoAdvance.value = null; } /// The current item is being replaced: Stopped goes out (not awaited: @@ -1449,6 +1504,12 @@ class SessionCoordinator { if (gen != _jfGen) return; final before = await userDataF; if (gen != _jfGen) return; + // The watch of this media (the tracker feeds it; Jellyfin's played + // state and resume point are what came before). + _sampleWatch(r, DateTime.now().toUtc()); + watch.before( + played: before?.played ?? false, + resumeAt: (before?.playbackPositionTicks ?? 0) / jellyfinTicksPerSecond); final reporter = JellyfinProgressReporter( client: plan.client, itemId: ref.itemId, @@ -1456,6 +1517,7 @@ class SessionCoordinator { playSessionId: plan.info.playSessionId, playedBefore: before?.played ?? false, resumeTicksBefore: before?.playbackPositionTicks ?? 0, + watch: watch.mediaId == r.mediaId ? watch.session : null, onUnauthorized: () => jellyfin.markExpired(ref.serverId), onStopped: () => jellyfin.watchedChanged(ref.serverId), onError: (e) { @@ -1545,7 +1607,7 @@ class SessionCoordinator { final next = nextEpisode.peek(); final duration = playback.durationSecs; var show = false; - if (next != null && duration > 0 && autoAdvance.peek() == null && r.transport != 'finished') { + if (next != null && duration > 0 && r.transport != 'finished') { final item = (jellyfinPlan.peek() as OwnPlay?)?.item; final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()), endingStart: playback.ending.peek()?.start); @@ -1554,9 +1616,8 @@ class SessionCoordinator { 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. + /// Per state: played to the end -> Stopped (with the watched rule's + /// verdict); seeked back from the end -> Playing again. void _jellyfinTransport(StateBroadcast r) { final ref = _jfRef; if (ref == null || r.mediaId != _jfMediaId) return; @@ -1571,48 +1632,7 @@ class SessionCoordinator { reporter.start(snap); } } - if (!finished) { - if (autoAdvance.peek() != null) cancelAutoAdvance(); - _advanceDoneFor = -1; - return; - } - nextPromptVisible.value = false; - final next = nextEpisode.peek(); - // Up next goes first; "Clear" in it hides the next episode here. - if (next == null || - !queue.continuationShown(r.mediaId) || - !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) == connection.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); + if (finished) nextPromptVisible.value = false; } String _accountKey(String serverId) => @@ -1667,11 +1687,18 @@ class SessionCoordinator { final tokenUrl = rejoin.oldToken.isNotEmpty && rejoin.oldToken != welcome.token && (_openUrl?.contains('/${rejoin.oldToken}/') ?? false); - if (changed || tokenUrl) { + // A restarted server without media while this app had some: it is + // offered back (and the player keeps playing it meanwhile). + final restoring = !rejoin.sameServer && before?.media != null && now != null && now.media == null; + if (restoring) { + _beginRestore(before!, rejoin.oldToken); + } else if (changed || tokenUrl) { _forgetMedia(); } else if (!rejoin.sameServer && _mediaId != -1) { // The same media under a restarted server's numbering: the player - // keeps it. + // keeps it, and so do the watch and the Jellyfin reports. + watch.renumber(before.mediaId, now.mediaId); + if (_jfMediaId == before.mediaId) _jfMediaId = now.mediaId; _mediaId = now.mediaId; media.renumber(now.mediaId); _seenMediaId = now.mediaId; @@ -1679,7 +1706,7 @@ class SessionCoordinator { // The intent the server lost: ready (unless the video changed and the // user unreadies then), and sitting out. var wantReady = _lastReady && !_lastAway; - if (changed && wantReady && _seenMediaId >= 0 && AppSettings.instance.unreadyOnSwap.peek()) { + if (changed && !restoring && wantReady && _seenMediaId >= 0 && settings.unreadyOnSwap.peek()) { wantReady = false; _lastReady = false; } @@ -1693,6 +1720,125 @@ class SessionCoordinator { } } + /// A restarted server answered without media while this app had [before] + /// up: one app puts it back (`swap.restore`; the server keeps the first + /// offer). Who offers is decided from the old state alone, so the apps + /// agree: who put it up or passes it on first, then the others by their + /// old member id, 1.5 s apart; each only while the server is still + /// empty and only what it can put up (see [_offerBack]). Given up after + /// [_restoreGiveUp]: then the next state is applied as it is. + void _beginRestore(StateBroadcast before, String oldToken) { + final self = _stateSelf; + _restore = (before: before, self: self, oldToken: oldToken, at: DateTime.now()); + queue.restoreFrom(before.mediaId); + final swapBy = before.media?['swapBy']; + final source = stateMediaRelay(before)?.source; + final first = self.isNotEmpty && (self == swapBy || self == source); + final others = [ + for (final m in before.members) + if (m.id != swapBy && m.id != source && isPresent(m)) m.id, + ]..sort(compareMemberIds); + final rank = others.indexOf(self); + final wait = first + ? const Duration(milliseconds: 500) + : Duration(milliseconds: 3000 + 1500 * (rank < 0 ? others.length : rank)); + _log.info('the server restarted without media: offering "${before.media?['name']}" back in ' + '${wait.inMilliseconds} ms unless someone does first'); + _restoreTimer?.cancel(); + _restoreTimer = Timer(wait, () => unawaited(_offerBack())); + } + + /// Puts what played back up the way it went up, from here, when this app + /// can: a Jellyfin item with its own account (it passes it on again), + /// its file, the link it put up (passed on, fetched by the server, or + /// opened by everyone), a link everyone opens. Where it was: this + /// player's position, else the old server's clock; and whether it + /// played. + Future _offerBack() async { + final hold = _restore; + if (hold == null) return; + _restoreTimer = Timer(_restoreGiveUp - DateTime.now().difference(hold.at), _giveUpRestore); + final r = serverState.peek(); + if (r == null || r.media != null || !connection.state.peek().online) return; + final before = hold.before; + final m = before.media!; + final holds = _playerReady && media.playerMediaId == before.mediaId && _openUrl != null; + final position = holds ? playback.positionSecs : serverPositionAt(before, connection.serverNow()); + final restore = SwapRestore(position: position, transport: before.transport); + final relay = stateMediaRelay(before); + final ownFile = _ownFile?.$1 == before.mediaId ? _ownFile!.$2 : null; + final ownLink = _ownLink?.$1 == before.mediaId ? _ownLink!.$2 : null; + final ref = refOfStateMedia(m); + String? error = 'nothing here to put it back from'; + if (ref != null) { + final pick = pickForRef(jellyfin, ref); + if (pick != null) { + swapJellyfin(pick, restore: restore); + error = null; + } + } else if (ownFile != null) { + error = await swapFile(ownFile, restore: restore); + } else { + final url = ownLink ?? stateMediaUrl(before); + final mode = relay == null ? LinkRelay.direct : (relay.fetch ? LinkRelay.server : LinkRelay.me); + if (url != null && (url.startsWith('http://') || url.startsWith('https://')) && (mode != LinkRelay.me || ownLink != null)) { + error = await swapLink(url, relay: mode, anyway: true, restore: restore); + } + } + _log.info(error == null + ? 'offered "${m['name']}" back at ${position.toStringAsFixed(1)} s (${before.transport})' + : 'cannot offer "${m['name']}" back: $error'); + } + + /// Nobody put it back: the player lets go of it with the next state. + void _giveUpRestore() { + if (_restore == null) return; + _log.info('nothing came back after the restart: letting go of what played'); + _restore = null; + _restoreTimer = null; + queue.restoreFrom(-1); + _forgetMedia(); + final r = serverState.peek(); + if (r != null) _onState(r); + } + + /// What played is up again ([r], a new mediaId) or other media is: the + /// same media carries on here, the player keeping it unless its URL + /// named the old server's token (a relay, the host's file: opened + /// again); the watch, the Jellyfin reports and what this member passes + /// on go on with it. Up next comes back too ([QueueController]). + void _landRestore(({StateBroadcast before, String self, String oldToken, DateTime at}) hold, StateBroadcast r) { + _restore = null; + _restoreTimer?.cancel(); + _restoreTimer = null; + final before = hold.before; + if (!_sameMedia(before.media, r.media)) { + _log.info('after the restart the server has other media: "${r.media?['name']}"'); + queue.restoreFrom(-1); + _forgetMedia(); + return; + } + _log.info('"${r.media?['name']}" is back as media ${r.mediaId}'); + watch.renumber(before.mediaId, r.mediaId); + if (_watchEndFor == before.mediaId) _watchEndFor = r.mediaId; + _seenMediaId = r.mediaId; + queue.restoreAfterRestart(before, hold.self, winner: r.media?['swapBy'] == connection.memberId); + final url = _openUrl; + final keep = _mediaId == before.mediaId && + media.playerMediaId == before.mediaId && + url != null && + (hold.oldToken.isEmpty || !url.contains('/${hold.oldToken}/')); + if (!keep) { + _forgetMedia(); + return; + } + if (_jfMediaId == before.mediaId) _jfMediaId = r.mediaId; + _mediaId = r.mediaId; + diff.reset(); + final plan = jellyfinPlan.peek(); + media.resumeAs(r, ownJellyfin: plan is OwnPlay ? (url: plan.url, headers: plan.headers) : null); + } + /// Whether two states' `media` are the same media (a restarted server /// counts its `mediaId` from 1 again). static bool _sameMedia(Map? a, Map? b) { @@ -1746,10 +1892,18 @@ class SessionCoordinator { _seekTimer?.cancel(); _seekTimer = null; _seekGate.reset(); + watch.close(); + _watchEndFor = -1; + _restore = null; + _restoreTimer?.cancel(); + _restoreTimer = null; + queue.restoreFrom(-1); await _stopJellyfin(); media.reset(); _pendingArt = null; _ownLink = null; + _ownFile = null; + ownFile.value = null; _playerReady = false; try { await media.release(); diff --git a/apps/desktop/lib/src/shell/chat_panel.dart b/apps/desktop/lib/src/shell/chat_panel.dart index 0aa5886..5ae84e9 100644 --- a/apps/desktop/lib/src/shell/chat_panel.dart +++ b/apps/desktop/lib/src/shell/chat_panel.dart @@ -278,7 +278,7 @@ Future _messageMenu(BuildContext context, String text, String? link) async case 'seek': return (Icons.fast_forward, scheme.primary, 'seeked'); case 'swap': - return (Icons.swap_horiz, scheme.primary, 'changed the media'); + return (Icons.swap_horiz, scheme.primary, f.reason == 'restore' ? 'put back what was playing' : 'changed the media'); case 'gone': return (Icons.cloud_off, Colors.orange, 'lost signal'); case 'return': @@ -296,6 +296,9 @@ Future _messageMenu(BuildContext context, String text, String? link) async case 'queue-skip': final why = f.reason.isEmpty ? '' : ' · ${f.reason}'; return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why'); + case 'advance-cancel': + final what = f.target == null || f.target!.isEmpty ? '' : ' (${f.target} is off Up next)'; + return (Icons.cancel_outlined, Colors.grey, 'cancelled what plays next$what'); case 'fetch-fail': return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}"); default: @@ -304,9 +307,16 @@ Future _messageMenu(BuildContext context, String text, String? link) async } } -/// The server's own lines (pause / resume). +/// The server's own lines (pause / resume, the end, an Up next item +/// nobody here can put up). (IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) { 'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'), + 'finished' => (Icons.stop_circle_outlined, Colors.grey, 'Finished'), + 'queue-skip' => ( + Icons.redo_rounded, + Colors.orange, + 'Skipped ${f.target ?? ''}${f.reason.isEmpty ? '' : ' · ${f.reason}'}' + ), _ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'), }; diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 39c2805..ac867f5 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -419,16 +419,14 @@ class _SessionPlayerState extends State { }); } - /// The action area's right end: the end-of-media card (Up next, else the - /// next episode; it stays up), else the pills, shown only with the + /// The action area's right end: the end-of-media card (Up next, else + /// the continuation; it stays up), else the pills, shown only with the /// controls ([PillGate]). Widget _actionSide() { final coord = _coord; return SignalBuilder( builder: (context) { final next = coord.nextEpisode.value; - final countdown = coord.autoAdvance.value; - // Up next goes before the next episode. final upNext = coord.queue.countdown.value; final finished = coord.serverState.value?.transport == 'finished'; final pill = coord.nextPromptVisible.value; @@ -437,18 +435,6 @@ class _SessionPlayerState extends State { if (upNext != null && finished) { return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext)); } - // "Clear" in Up next hides the next episode for this media. - if (next != null && (finished || countdown != null) && coord.queue.shownContinuation.value.isNotEmpty) { - return ControlsHoldRegion( - hold: _hold, - child: EndOfEpisodeCard( - next: next, - countdown: countdown, - onPlayNow: coord.playNextEpisode, - onCancel: coord.cancelAutoAdvance, - ), - ); - } if (!showNext && skip == null) return const SizedBox.shrink(); return Column( mainAxisSize: MainAxisSize.min, diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 7d47771..d69c5c1 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -152,7 +152,7 @@ class SettingsTab extends StatelessWidget { } }, ), - const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()), + const SettingsPanel(title: 'Episodes', child: AutoAdvanceSetting()), const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()), ]; case SettingsSection.notifications: diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index f91e2f8..6484ebe 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -59,7 +59,9 @@ 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. + /// An episode this app puts up (Jellyfin, a file of a local show) is + /// continued by the next one (Up next's `auto` item, the server's + /// countdown at its end). final autoAdvance = signal(true); /// Session: send ready=false when the video changes (a swap keeps the /// server's ready flags). diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index 678f40c..e743603 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -288,8 +288,8 @@ class SyncClient { _send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson()); } - void sendSwap(HelloMedia media, {String? queueId}) { - _send(SwapIntent(media: media, queueId: queueId).toJson()); + void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) { + _send(SwapIntent(media: media, queueId: queueId, restore: restore).toJson()); } /// One change to Up next (see [QueueIntent]). diff --git a/apps/desktop/lib/src/watch/watch_history.dart b/apps/desktop/lib/src/watch/watch_history.dart new file mode 100644 index 0000000..33543ee --- /dev/null +++ b/apps/desktop/lib/src/watch/watch_history.dart @@ -0,0 +1,187 @@ +import 'package:flutter/foundation.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; + +// The device's watch history (device-owned, one Hive box): what this app +// watched of files and links, by [WatchKey], with the verdict of the +// watched rule (`WatchSession`, D3). Jellyfin keeps its own (reported by +// `JellyfinProgressReporter`); AniList later reads the tracker's records. +// See ARCHITECTURE.md "Watch tracking". + +const watchHistoryBoxName = 'watch_history'; + +/// What a watch is about, independent of the opening: a file by its path +/// on this computer, a link by its URL, a Jellyfin item by its ids, else +/// the media's name and size (someone else's file, a link passed on). +class WatchKey { + final String value; + const WatchKey._(this.value); + + factory WatchKey.file(String path) => WatchKey._('file:$path'); + factory WatchKey.link(String url) => WatchKey._('link:$url'); + factory WatchKey.jellyfin(JellyfinRef ref) => WatchKey._('jf:${ref.serverId}/${ref.itemId}'); + factory WatchKey.media(String name, int size) => WatchKey._('media:$name|$size'); + + bool get isJellyfin => value.startsWith('jf:'); + + /// The key of `state.media` [media]: [ownFile] / [ownLink] when this app + /// put it up from there, else its Jellyfin ref, its shared link, its + /// name and size. + static WatchKey? of(Map? media, {String? ownFile, String? ownLink}) { + if (media == null) return null; + if (ownFile != null) return WatchKey.file(ownFile); + final jf = media['jellyfin']; + if (jf is Map) { + final ref = JellyfinRef.fromJson(Map.from(jf)); + if (ref.serverId.isNotEmpty && ref.itemId.isNotEmpty) return WatchKey.jellyfin(ref); + } + if (ownLink != null) return WatchKey.link(ownLink); + final url = media['url']; + if (url is String && (url.startsWith('http://') || url.startsWith('https://'))) return WatchKey.link(url); + final name = media['name']; + final size = media['size']; + return WatchKey.media(name is String ? name : '', size is num ? size.toInt() : 0); + } + + @override + bool operator ==(Object other) => other is WatchKey && other.value == value; + @override + int get hashCode => value.hashCode; + @override + String toString() => value; +} + +/// One closed watch: what the tracker hands its sinks (the device history, +/// AniList later). +class WatchRecord { + final WatchKey key; + final String title; + final MediaRef? ref; + final WatchVerdict verdict; + /// Seconds the media played while this viewer was In, and its runtime + /// (0 when unknown). + final double timeIn; + final double runtime; + final DateTime at; + const WatchRecord({ + required this.key, + required this.title, + required this.verdict, + required this.timeIn, + required this.runtime, + required this.at, + this.ref, + }); + + @override + String toString() => 'WatchRecord($key, $verdict, ${timeIn.round()} s In of ${runtime.round()} s)'; +} + +/// What the history holds for a key: watched (played), or where to +/// resume, and when. +class WatchEntry { + final bool played; + /// Seconds; 0 when played or none. + final double resumeAt; + final String title; + final MediaRef? ref; + final DateTime at; + const WatchEntry({required this.played, this.resumeAt = 0, this.title = '', this.ref, required this.at}); + + Map toJson() => { + 'played': played, + if (resumeAt > 0) 'resumeAt': resumeAt, + if (title.isNotEmpty) 'title': title, + if (ref != null) 'ref': ref!.toJson(), + 'at': at.toUtc().toIso8601String(), + }; + + static WatchEntry? fromJson(Object? j) { + if (j is! Map) return null; + final at = DateTime.tryParse('${j['at']}'); + if (at == null) return null; + final resume = j['resumeAt']; + MediaRef? ref; + try { + ref = MediaRef.fromJson(j['ref']); + } catch (_) {} + return WatchEntry( + played: j['played'] == true, + resumeAt: resume is num && resume.isFinite && resume > 0 ? resume.toDouble() : 0, + title: j['title'] is String ? j['title'] as String : '', + ref: ref, + at: at, + ); + } +} + +/// The device's watch history: [entry] per key, [record] from the +/// tracker. [revision] bumps on every change (pages that show it listen). +class WatchHistory { + final Map _memory = {}; + /// False: memory only (tests). + final bool persist; + Box? _box; + Future? _load; + final revision = signal(0); + + WatchHistory({this.persist = true}); + + Future ensureLoaded() => _load ??= () async { + if (!persist) return; + try { + final box = _box = await Hive.openBox(watchHistoryBoxName); + for (final e in _memory.entries) { + await box.put(e.key, e.value); + } + _memory.clear(); + revision.value++; + } catch (e, st) { + debugPrint('open watch history failed: $e\n$st'); + } + }(); + + WatchEntry? entry(WatchKey key) => WatchEntry.fromJson(_box?.get(key.value) ?? _memory[key.value]); + + /// A closed watch: played -> played (the resume point goes); part way + /// -> its resume point, unless it was played before (it stays played); + /// a peek changes nothing. Jellyfin items are not kept here. + void record(WatchRecord r) { + if (r.key.isJellyfin) return; + final before = entry(r.key); + final WatchEntry next; + switch (r.verdict.outcome) { + case WatchOutcome.played || WatchOutcome.keepPlayed: + next = WatchEntry(played: true, title: r.title, ref: r.ref, at: r.at); + case WatchOutcome.partial: + next = WatchEntry( + played: before?.played ?? false, + resumeAt: (before?.played ?? false) ? 0 : r.verdict.resumeAt ?? 0, + title: r.title, + ref: r.ref, + at: r.at); + case WatchOutcome.peek: + return; + } + final json = next.toJson(); + final box = _box; + if (box == null) { + _memory[r.key.value] = json; + } else { + box.put(r.key.value, json).catchError((Object e) => debugPrint('watch history put ${r.key}: $e')); + } + revision.value++; + } + + /// Every entry, newest first. + List<(WatchKey, WatchEntry)> get entries { + final raw = {..._memory, if (_box != null) for (final k in _box!.keys) '$k': _box!.get(k)}; + final out = [ + for (final e in raw.entries) + if (WatchEntry.fromJson(e.value) case final w?) (WatchKey._(e.key), w), + ]; + out.sort((a, b) => b.$2.at.compareTo(a.$2.at)); + return out; + } +} diff --git a/apps/desktop/lib/src/watch/watch_tracker.dart b/apps/desktop/lib/src/watch/watch_tracker.dart new file mode 100644 index 0000000..c029537 --- /dev/null +++ b/apps/desktop/lib/src/watch/watch_tracker.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:sync_relay_core/core.dart'; + +import 'watch_history.dart'; + +// Watch tracking (ARCHITECTURE.md "Watch tracking", D3): one watch per +// media of the server (its `mediaId`), fed every second by the session +// with what this app sees, judged by core's `WatchSession` when it ends. +// The verdict goes to the device history (files and links) and out on +// [WatchTracker.records] (the seam for AniList later); Jellyfin's +// played/resume reports read the same session. + +final _log = createLogger('watch'); + +/// What the session hands the tracker each second. +class WatchSample { + /// The server's media and its id. + final int mediaId; + final Map? media; + /// This app's own source of it: the file it put up, the link it put up. + final String? ownFile; + final String? ownLink; + final double position; + /// The media plays here: the server plays it and this player has it. + final bool playing; + /// This viewer is In (`in`): not away, joining or gone. + final bool present; + /// The player's duration (0 while unknown) and where the ending starts. + final double duration; + final double? endingStart; + final DateTime at; + const WatchSample({ + required this.mediaId, + required this.media, + required this.position, + required this.playing, + required this.present, + required this.at, + this.ownFile, + this.ownLink, + this.duration = 0, + this.endingStart, + }); +} + +class WatchTracker { + final WatchHistory history; + WatchTracker({WatchHistory? history}) : history = history ?? WatchHistory(persist: false); + + final _records = StreamController.broadcast(); + + /// Every closed watch (peeks too): the device history takes files and + /// links; AniList progress later listens here. + Stream get records => _records.stream; + + int _mediaId = -1; + WatchKey? _key; + String _title = ''; + MediaRef? _ref; + double _runtime = 0; + double? _endingStart; + WatchSession? _session; + + /// The watch of the media playing (null before the first sample). + WatchSession? get session => _session; + WatchKey? get key => _key; + + /// One second of the session. A new media closes the watch of the one + /// before. + void sample(WatchSample s) { + if (s.mediaId != _mediaId) { + close(at: s.at); + if (s.media == null) return; + _open(s); + } + final session = _session; + if (session == null) return; + // A file or link this app put up is known once its swap landed. + final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink); + if (key != null && key != _key && !(_key?.isJellyfin ?? false)) _key = key; + if (s.duration > 0) _runtime = s.duration; + _endingStart = s.endingStart; + session.sample(s.position, playing: s.playing, present: s.present, at: s.at); + } + + void _open(WatchSample s) { + final key = WatchKey.of(s.media, ownFile: s.ownFile, ownLink: s.ownLink); + final before = key == null ? null : history.entry(key); + _mediaId = s.mediaId; + _key = key; + _title = '${s.media?['name'] ?? ''}'; + try { + _ref = MediaRef.fromJson(s.media?['ref']); + } catch (_) { + _ref = null; + } + final d = s.media?['duration']; + _runtime = d is num && d > 0 ? d.toDouble() : 0; + _endingStart = null; + _session = WatchSession(playedBefore: before?.played ?? false, resumeBefore: before?.resumeAt ?? 0); + } + + /// The same media goes on under another [mediaId] (a restarted server + /// numbers it anew): the watch carries on. + void renumber(int from, int to) { + if (_mediaId == from) _mediaId = to; + } + + /// What Jellyfin says about the item before this opening (its played + /// state and resume point, read once it is open). + void before({required bool played, double resumeAt = 0}) { + final session = _session; + if (session == null) return; + session + ..playedBefore = played + ..resumeBefore = resumeAt; + } + + /// The verdict so far (the runtime as last seen); null without a watch. + WatchVerdict? get verdict => _session?.verdict(runtime: _runtime, endingStart: _endingStart); + + /// Records the watch as it stands (the end of the media; it may go on + /// after a seek back and be recorded again). + WatchRecord? checkpoint({DateTime? at}) { + final session = _session, key = _key; + if (session == null || key == null) return null; + final v = session.verdict(runtime: _runtime, endingStart: _endingStart); + final r = WatchRecord( + key: key, + title: _title, + ref: _ref, + verdict: v, + timeIn: session.timeIn, + runtime: _runtime, + at: at ?? DateTime.now().toUtc()); + _log.info('$key: ${v.outcome.name} (${session.timeIn.round()} s In of ${_runtime.round()} s)'); + history.record(r); + _records.add(r); + return r; + } + + /// The watch ends (another media, leaving): recorded and forgotten. + WatchRecord? close({DateTime? at}) { + final r = _session == null ? null : checkpoint(at: at); + _mediaId = -1; + _key = null; + _session = null; + _ref = null; + _runtime = 0; + _endingStart = null; + return r; + } + + /// The media the current watch is of (-1: none). + int get mediaId => _mediaId; + + Future dispose() => _records.close(); +} diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart index 204c11b..30b7a41 100644 --- a/apps/desktop/test/coord_presence_test.dart +++ b/apps/desktop/test/coord_presence_test.dart @@ -362,6 +362,87 @@ void main() { } }); + test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, ' + 'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async { + final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart')); + await first.start(); + final port = first.boundPort; + final origin = await _origin(await _media()); + SyncServer? second; + try { + final alice = await member(port, 'alice'); + final bob = await member(port, 'bob'); + await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv'); + expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull); + await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item'); + await Future.delayed(const Duration(seconds: 4)); + final opens = [alice.opens, bob.opens]; + final watch = alice.watch.session; + final watched = watch!.timeIn; + expect(watched, greaterThan(2)); + + await first.stop(); + await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting'); + second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart')); + await second.start(); + final engine = second.hub.engine; + await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30)); + expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1'); + expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start'); + await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'), + 'playing again', timeout: const Duration(seconds: 20)); + expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened'); + await _poll(() => engine.queue.items.length == 1, 'Up next back'); + expect(engine.queue.items.single.media.url, endsWith('/next.mkv')); + // The watch went on through the restart (one watch, still counting). + await Future.delayed(const Duration(seconds: 2)); + expect(identical(alice.watch.session, watch), isTrue); + expect(alice.watch.mediaId, engine.mediaId); + expect(watch.timeIn, greaterThan(watched + 1)); + } finally { + await second?.stop(); + await origin.close(force: true); + } + }); + + test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again', + timeout: const Timeout(Duration(minutes: 2)), () async { + final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart')); + await first.start(); + final port = first.boundPort; + final file = await _media(); + SyncServer? second; + try { + final alice = await member(port, 'alice'); + final bob = await member(port, 'bob'); + expect(await alice.swapPicked(FileMedia(file)), isNull); + await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it', + timeout: const Duration(seconds: 30)); + for (final x in [alice, bob]) { + x.toggleReady(); + } + await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing'); + await Future.delayed(const Duration(seconds: 3)); + final aliceOpens = alice.opens; + + await first.stop(); + await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting'); + second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart')); + await second.start(); + final hub = second.hub; + await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back', + timeout: const Duration(seconds: 30)); + expect(hub.engine.positionAt(), greaterThan(3)); + await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again'); + await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'), + 'playing again', timeout: const Duration(seconds: 30)); + expect(alice.opens, aliceOpens, reason: 'her own file stayed open'); + expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay'); + } finally { + await second?.stop(); + } + }); + test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async { final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel')); await server.start(); diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index b5bcea8..94045db 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -1,5 +1,3 @@ -// restructure: rewrite in step 6 (Watch tracking): the progress-reporting -// checks; the guest-through-the-relay flow stays. import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/playback/media_marks.dart'; import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/session/session_coordinator.dart'; +import 'package:starsync/src/state/settings.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): @@ -220,7 +219,8 @@ 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', + 'account holders direct-play and report what they watched, the guest plays through the relay, ' + 'the next episode continues it for everyone', timeout: const Timeout(Duration(minutes: 4)), () async { MediaKit.ensureInitialized(); @@ -296,6 +296,11 @@ void main() { expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos'); } + // Carol sits out for the rest of the episode: she is not tracked as + // having watched it (P7). + c.sitOut(); + await _poll(() => c.selfAway.peek(), 'carol away'); + // Pause (A unreadies): an immediate Progress with IsPaused. a.toggleReady(); await _poll(() => !a.playback.player.state.playing, 'held'); @@ -304,7 +309,7 @@ void main() { _kind(s) == 'Progress' && s.body?['IsPaused'] == true), 'paused progress'); a.toggleReady(); - await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again'); + await _poll(() => [a, b].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. @@ -319,23 +324,28 @@ void main() { _kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2), 'track change reported'); - // Near the end: finished -> Stopped, then one auto-advance swap. - a.seekTo(16); + // Alice put the episode up and has the next one: it continues it, + // in Up next for everyone. + await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation'); + final auto = b.queue.continues.peek()!; + expect(auto.by, a.connection.memberId); + + // Played to the end: finished -> Stopped, the server's countdown + // on every screen, then one swap. await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished', - timeout: const Duration(seconds: 20)); + timeout: const Duration(seconds: 40)); 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, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), + 'everyone sees the countdown'); + expect(a.serverState.peek()!.advance!.id, auto.id); + expect(a.queue.countdown.peek()!.item.id, auto.id); await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced', - timeout: const Duration(seconds: 20)); + timeout: const Duration(seconds: 25)); 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); + expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation'); + c.joinBack(); await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null), 'all opened the next episode'); expect(b.jellyfinPlan.peek(), isA()); @@ -383,13 +393,17 @@ void main() { expect(ep1.last, 'Stopped'); expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); - // Part of it watched, then a seek to the end: not watched (see - // WatchSession). A resume point capped at 85 % of the 20 s, so - // Jellyfin cannot mark it played on its own. - expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000), - reason: 'part way: resume point under 90 %, so not marked played'); - expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse, - reason: 'seeking to the end is not watching'); + // Alice watched it through (time In, see WatchSession): Stopped + // without a position, then marked played. + expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played'); + bool markedPlayed(String token) => jelly.calls.any((c) => + c.method == 'POST' && + c.path == '/UserPlayedItems/$_ep1' && + _FakeJellyfin.authFields(c.auth)['Token'] == token); + expect(markedPlayed('ATOKEN'), isTrue); + expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)'); + final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1); + expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports'); expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1'); expect(ep2.first, 'Playing'); expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); @@ -442,7 +456,12 @@ void main() { 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())); + // No continuation: its warm relay would be read while the test stops + // (a cut relay response fails a test, see DEVELOPMENT "Traps"). + final a = SessionCoordinator( + jellyfin: accA, + trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), + settings: AppSettings()..autoAdvance.value = false); final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); try { await a.connect(ConnectParams( diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index ab727c8..27366a7 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -1,5 +1,3 @@ -// restructure: rewrite in step 6 (What's next + Watch tracking): the -// "progress" and "episodes" groups (reporting, auto-advance swapper). import 'dart:convert'; import 'package:flutter/gestures.dart'; @@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/session/jellyfin_widgets.dart'; +import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown; +import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard; import 'package:starsync/src/session/delivery.dart'; -import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance; import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'jellyfin_accounts_test.dart'; @@ -212,9 +211,12 @@ void main() { }); }); + // The reporter reads the watch the session's tracker feeds (time In, + // D3); here the test feeds it. group('progress', () { late JellyfinAccounts accounts; late DateTime now; + late WatchSession watch; late JellyfinProgressReporter reporter; final errors = []; var unauthorized = 0; @@ -224,27 +226,35 @@ void main() { now = DateTime(2026, 1, 1, 20); errors.clear(); unauthorized = 0; + watch = WatchSession(); reporter = JellyfinProgressReporter( client: accounts.clientFor(fixtureServerId)!, itemId: episodeId, source: fixtureSource(), playSessionId: playSessionId, + watch: watch, clock: () => now, onError: errors.add, onUnauthorized: () => unauthorized++, ); }); + /// One second of the session at wall second [secs]: the watch gets its + /// sample (In unless [away]), the reporter its snapshot. + void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) { + now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs)); + watch.sample(pos, playing: !paused, present: !away, at: now); + reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid)); + } + 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)); - } + void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) => + tick(secs, pos, paused: paused, aid: aid, sid: sid); at(1, 0); // resume -> Progress at(2, 1); @@ -282,15 +292,14 @@ void main() { 'MediaSourceId': episodeId, 'PlaySessionId': playSessionId, 'PositionTicks': 0, - }, reason: '11 s watched is a peek: the resume point from before (none) stays'); + }, reason: '11 s In is a peek: the resume point from before (none) stays'); expect(errors, isEmpty); }); test('watched to the end: Stopped without a position, then marked played', () async { reporter.start(const PlayerSnapshot(position: 0, paused: false)); - for (var i = 1; i <= 1420; i++) { - now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i)); - reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false)); + for (var i = 0; i <= 1420; i++) { + tick(i, i.toDouble()); } await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3), @@ -302,6 +311,19 @@ void main() { expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide'); }); + test('sat out for most of it: a resume point, never played (P7)', () async { + reporter.start(const PlayerSnapshot(position: 0, paused: false)); + for (var i = 0; i <= 1420; i++) { + // In for five minutes, then away while the others watch on. + tick(i, i.toDouble(), away: i > 300); + } + await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); + expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems'))); + final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body; + expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond, + reason: 'it resumes where this viewer stopped watching'); + }); + test('played before: a peek never unmarks it', () async { final r = JellyfinProgressReporter( client: accounts.clientFor(fixtureServerId)!, @@ -376,21 +398,6 @@ void main() { expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); }); - 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}, gone: 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 route: via server only for relay viewers of the current item', () { DeliveryRoute? route(MemberView m, {bool relay = true}) => routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay); @@ -508,23 +515,36 @@ void main() { const JellyfinMedia( serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); - testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { - var play = 0, cancel = 0; - await tester.pumpWidget(scoped(_graph, 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); + testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up", + (tester) async { + final queue = _graph.coordinator.queue; + const item = QueueItem( + id: 'q4', + media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)), + how: QueueHow.jellyfin, + title: 'Battle with the Heroes', + sub: 'S1E7', + by: 'm1', + byName: 'Ann', + auto: true, + forMedia: 3); + await tester.pumpWidget(scoped(_graph, app(UpNextCard( + queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7))))); + expect(find.text('Next in 7 s'), findsOneWidget); + expect(find.text('Battle with the Heroes'), findsOneWidget); + expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget); + expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget); - await tester.pumpWidget(scoped(_graph, 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); + // Cancelled (for everyone): Play now only. + await tester.pumpWidget(scoped(_graph, app(UpNextCard( + queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item))))); + expect(find.text('Next'), findsOneWidget); + expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing); + + // Ran out while the one who puts it up is gone: the card says so. + await tester.pumpWidget(scoped(_graph, app(UpNextCard( + queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann'))))); + expect(find.text('Waiting for Ann'), findsOneWidget); }); testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async { diff --git a/apps/desktop/test/queue_logic_test.dart b/apps/desktop/test/queue_logic_test.dart index c18ccd5..625a486 100644 --- a/apps/desktop/test/queue_logic_test.dart +++ b/apps/desktop/test/queue_logic_test.dart @@ -1,25 +1,20 @@ -// restructure: rewrite in step 6 (What's next): who performs an item and -// the janitor become server state. import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; +import 'package:starsync/src/files/scanner.dart' show FolderScan; +import 'package:starsync/src/library/library_store.dart'; +import 'package:starsync/src/library/local_next.dart'; +import 'package:starsync/src/library/show_matcher.dart'; import 'package:starsync/src/queue/queue_logic.dart'; -MemberView member(String id, - {bool gone = false, bool away = false, Map jf = const {}, String name = ''}) => - MemberView( - id: id, - name: name.isEmpty ? id : name, - ready: false, - inSet: true, - buffered: 0, - presence: gone ? Presence.gone : away ? Presence.away : Presence.watching, - jf: jf); +import 'show_matcher_test.dart' show FakeAniList; + +// What's next on the app: an item's words, and what continues a file of +// a local show (who puts an item up and the countdown are core's and the +// server's: core `whats_next_test`, `queue_test`). QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem( id: 'q1', - media: how == QueueHow.jellyfin - ? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')) - : const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'), + media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'), how: how, title: 'x', by: by, @@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue ); void main() { - group('queuePerformer', () { - test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () { - for (final how in [QueueHow.through, QueueHow.file]) { - expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2'); - final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]); - expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace'); - final left = queuePerformer(item(how), [member('m1'), member('m3')]); - expect(left.id, isNull); - expect(left.why, 'Bob left'); - } - }); - - test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () { - final access = {'S': true}; - expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2'); - expect( - queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')]) - .id, - 'm3'); - expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why, - isNotNull); - }); - - test('a link everyone opens or the server fetches: the adder, else the lowest active member', () { - for (final how in [QueueHow.direct, QueueHow.server]) { - expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2'); - expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3'); - final nobody = queuePerformer(item(how), [member('m2', away: true)]); - expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip'); - } - }); - }); - - test('the janitor is the lowest member that is not gone', () { - expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2'); - }); - test('labels', () { expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob'); expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file"); + expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file"); expect(queueClock(3725), '1:02:05'); expect(queueClock(65), '1:05'); expect(queueClock(0), ''); }); + + group('what continues a file of a local show', () { + const dir = '/lib/Bleach'; + String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv'; + // 064 and 065 are there, 066..107 are not. + final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))]; + late List scanned; + late LocalNextFinder finder; + late ShowMatcher matcher; + + setUp(() { + scanned = []; + matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {}); + finder = LocalNextFinder( + matcher: matcher, + scan: (d) async { + scanned.add(d); + return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false); + }); + }); + + test('from its own folder when the library has not seen it this run', () async { + final next = await finder.nextAfter(ep(64)); + expect(next!.episode.path, ep(65)); + expect(next.skipped, isNull); + expect(scanned, [dir]); + }); + + test('numbers skipped on the way are said', () async { + final next = await finder.nextAfter(ep(65)); + expect(next!.episode.path, ep(108)); + expect(next.skipped!.label, '66–107'); + }); + + test('the last one, or not an episode: nothing continues it', () async { + expect(await finder.nextAfter(ep(109)), isNull); + expect(await finder.nextAfter('$dir/notes.mkv'), isNull); + }); + + test('the library the Files page saw is used without a scan', () async { + matcher.remember(groupLocal(files, roots: const ['/lib'])); + expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64)); + expect(scanned, isEmpty); + }); + }); } diff --git a/apps/desktop/test/queue_switch_e2e_test.dart b/apps/desktop/test/queue_switch_e2e_test.dart index 0721c0b..ac92812 100644 --- a/apps/desktop/test/queue_switch_e2e_test.dart +++ b/apps/desktop/test/queue_switch_e2e_test.dart @@ -14,6 +14,7 @@ import 'package:starsync/src/queue/queue_controller.dart' show WarmState; import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/switch_trace.dart'; +import 'package:starsync/src/state/settings.dart'; // How long an Up next switch takes through the media pipeline, end to // end, over real mpv and a real sync server: two Jellyfin account holders @@ -244,8 +245,14 @@ void main() { final hosts = {}; SessionCoordinator coord(JellyfinAccounts acc) { final host = MediaKitHost()..init(); + // Up next alone: no continuation of the episodes (it would be + // queued after the items this test adds and times). final c = SessionCoordinator( - jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host) + jellyfin: acc, + trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), + ytDlp: ytDlp, + players: host, + settings: AppSettings()..autoAdvance.value = false) ..media.preloadEnabled = preload; hosts[c] = host; return c; diff --git a/apps/desktop/test/singletons_test.dart b/apps/desktop/test/singletons_test.dart index 9798285..c5bfea4 100644 --- a/apps/desktop/test/singletons_test.dart +++ b/apps/desktop/test/singletons_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; /// (`AppGraph`, widgets through `AppScope`); this list may only shrink. /// Lower a number when a use goes; never raise one or add a class. const baseline = { - 'AppSettings': 55, + 'AppSettings': 49, 'JellyfinAccounts': 10, 'UpdateController': 8, 'Profile': 8, diff --git a/apps/desktop/test/watch_tracker_test.dart b/apps/desktop/test/watch_tracker_test.dart new file mode 100644 index 0000000..b40e078 --- /dev/null +++ b/apps/desktop/test/watch_tracker_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:starsync/src/watch/watch_history.dart'; +import 'package:starsync/src/watch/watch_tracker.dart'; + +// Watch tracking (D3): one watch per media of the server, time In from the +// presence the server reports, the device history for files and links, +// every closed watch out on `records` (the seam for AniList later). + +final _t0 = DateTime.utc(2026, 1, 1); + +void main() { + late WatchTracker tracker; + late List records; + + setUp(() { + tracker = WatchTracker(history: WatchHistory(persist: false)); + records = []; + tracker.records.listen(records.add); + }); + + tearDown(() => tracker.dispose()); + + const episode = {'name': 'Bleach - 064.mkv', 'size': 100, 'duration': 1440, 'ref': {'kind': 'episode', 'title': 'Bleach', 'absolute': 64}}; + + /// [secs] seconds of media [mediaId] from wall second [t], playing, + /// with self [present] or not. + int run(int mediaId, Map media, int t, int secs, + {bool present = true, bool playing = true, String? ownFile, double from = 0}) { + for (var i = 0; i <= secs; i++) { + tracker.sample(WatchSample( + mediaId: mediaId, + media: media, + ownFile: ownFile, + position: from + i, + playing: playing, + present: present, + duration: 1440, + at: _t0.add(Duration(seconds: t + i)), + )); + } + return t + secs + 1; + } + + test('a file watched through goes into the history as watched; the next media closes the watch', () async { + const path = '/lib/Bleach/Bleach - 064.mkv'; + final t = run(2, episode, 0, 1200, ownFile: path); + run(3, const {'name': 'next', 'size': 1}, t, 1); + await Future.delayed(Duration.zero); + expect(records.first.key, WatchKey.file(path)); + expect(records.first.verdict.outcome, WatchOutcome.played); + expect(records.first.ref?.absolute, 64); + final e = tracker.history.entry(WatchKey.file(path))!; + expect((e.played, e.resumeAt, e.title), (true, 0.0, 'Bleach - 064.mkv')); + expect(tracker.mediaId, 3); + }); + + test('sat out for most of it: not watched (P7), a resume point instead', () async { + const url = 'https://example.com/v.mp4'; + final media = {'name': 'v', 'size': 0, 'duration': 1440, 'url': url}; + var t = run(2, media, 0, 300); + t = run(2, media, t, 1100, present: false, from: 300); + tracker.close(at: _t0.add(Duration(seconds: t))); + await Future.delayed(Duration.zero); + expect(records.single.verdict.outcome, WatchOutcome.partial); + expect(records.single.timeIn, closeTo(300, 2)); + final e = tracker.history.entry(WatchKey.link(url))!; + expect(e.played, isFalse); + expect(e.resumeAt, closeTo(300, 1), reason: 'where this viewer was when it sat out'); + }); + + test('a peek changes nothing; a played item stays played', () async { + const path = '/lib/x.mkv'; + run(2, episode, 0, 1200, ownFile: path); + tracker.close(); + expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue); + run(4, episode, 0, 20, ownFile: path); + expect(tracker.session!.playedBefore, isTrue, reason: 'the history says it was watched'); + tracker.close(); + expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue); + final other = WatchKey.of(const {'name': 'peeked', 'size': 5})!; + run(5, const {'name': 'peeked', 'size': 5, 'duration': 1440}, 0, 20); + tracker.close(); + expect(tracker.history.entry(other), isNull); + }); + + test('Jellyfin: the watch is shared with its reports, not kept in the device history', () async { + final media = { + 'name': 'ep', + 'size': 0, + 'duration': 1440, + 'jellyfin': {'serverId': 'S', 'itemId': 'i'}, + }; + run(2, media, 0, 10); + tracker.before(played: true, resumeAt: 0); + expect(tracker.verdict!.outcome, WatchOutcome.keepPlayed); + tracker.close(); + await Future.delayed(Duration.zero); + expect(records.single.key.isJellyfin, isTrue); + expect(tracker.history.entries, isEmpty); + }); + + test('keys: own file, Jellyfin, own or shared link, else name and size', () { + expect(WatchKey.of(const {'name': 'a', 'size': 1}, ownFile: '/x.mkv'), WatchKey.file('/x.mkv')); + expect( + WatchKey.of(const { + 'name': 'a', + 'jellyfin': {'serverId': 'S', 'itemId': 'i'} + }), + WatchKey.jellyfin(const JellyfinRef(serverId: 'S', itemId: 'i'))); + expect(WatchKey.of(const {'name': 'a', 'relay': {}}, ownLink: 'https://x/y'), WatchKey.link('https://x/y')); + expect(WatchKey.of(const {'name': 'a', 'url': 'https://x/z'}), WatchKey.link('https://x/z')); + expect(WatchKey.of(const {'name': 'a', 'size': 3}), WatchKey.media('a', 3)); + expect(WatchKey.of(null), isNull); + }); +} diff --git a/apps/desktop/test/whats_next_e2e_test.dart b/apps/desktop/test/whats_next_e2e_test.dart new file mode 100644 index 0000000..3f07e1b --- /dev/null +++ b/apps/desktop/test/whats_next_e2e_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +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:starsync/src/files/scanner.dart' show scanFolder; +import 'package:starsync/src/jellyfin/accounts.dart'; +import 'package:starsync/src/jellyfin/track_prefs.dart'; +import 'package:starsync/src/library/library_store.dart'; +import 'package:starsync/src/library/local_next.dart'; +import 'package:starsync/src/library/show_matcher.dart'; +import 'package:starsync/src/screens/home/picked_media.dart'; +import 'package:starsync/src/session/session_coordinator.dart'; +import 'package:starsync/src/watch/watch_history.dart'; +import 'package:starsync/src/watch/watch_tracker.dart'; + +import 'show_matcher_test.dart' show FakeAniList; + +// What's next with real coordinators, real mpv and a dedicated server: a +// file of a local show put up by a member is continued by the show's next +// episode (numbers skipped on the way said), the server's countdown runs +// on every screen, the member who has the file puts it up, a cancel is +// shared, and what was watched goes into the device's watch history. + +final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; +final _show = '$_tmp/whats_next_e2e/Show'; + +/// `Show - NN.mp4`, 6 s each, with audio (headless mpv needs it) and the +/// index first. +Future _episode(int n) async { + final path = '$_show/Show - ${n.toString().padLeft(2, '0')}.mp4'; + if (await File(path).exists()) return path; + await Directory(_show).create(recursive: true); + final r = await Process.run('ffmpeg', [ + '-y', '-v', 'error', + '-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', + '-f', 'lavfi', '-i', 'sine=frequency=${300 + 100 * n}:sample_rate=48000', + '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-movflags', '+faststart', path, + ]); + if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}'); + return path; +} + +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'); +} + +void main() { + late SyncServer server; + late List all; + late List serverErrors; + + Future member(String name, {Future Function(String)? nextFile}) async { + final acc = JellyfinAccounts(store: MemoryJellyfinStore()); + await acc.ensureLoaded(validate: false); + final c = SessionCoordinator( + jellyfin: acc, + trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), + watch: WatchTracker(history: WatchHistory(persist: false))) + ..nextFile = nextFile; + all.add(c); + await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name)); + await c.playback.player.setVolume(0); + return c; + } + + setUp(() async { + MediaKit.ensureInitialized(); + all = []; + server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps')); + // A relay read cut short at a swap or the stop is reported to the + // zone the server started in (DEVELOPMENT "Traps"). + serverErrors = []; + await runZonedGuarded(server.start, (e, _) { + if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return; + serverErrors.add(e); + }); + }); + + tearDown(() async { + for (final x in all) { + await x.dispose(); + } + await server.stop(); + expect(serverErrors, isEmpty, reason: 'server errors'); + }); + + test('a file of a show continues with its next episode; the countdown and a cancel are shared', + timeout: const Timeout(Duration(minutes: 3)), () async { + final ep1 = await _episode(1); + for (final n in [3, 4]) { + await _episode(n); + } + final matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {}); + final alice = await member('alice', nextFile: LocalNextFinder(matcher: matcher, scan: scanFolder).nextAfter); + final bob = await member('bob'); + + expect(await alice.swapPicked(FileMedia(ep1)), isNull); + await _poll(() => bob.queue.continues.peek() != null, 'the continuation, for everyone'); + final next = bob.queue.continues.peek()!; + expect((next.how, next.by, next.auto), (QueueHow.file, alice.connection.memberId, true)); + expect(next.media.name, 'Show - 03.mp4'); + expect(next.sub, contains('skips 2')); + expect(bob.queue.items.peek(), isEmpty, reason: 'it is not an Up next item of its own'); + + // Both ready: it plays to its end; the countdown is on both screens. + await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5), 'both opened episode 1'); + final first = alice.serverState.peek()!.mediaId; + for (final x in [alice, bob]) { + x.toggleReady(); + } + await _poll(() => alice.serverState.peek()?.transport == 'finished', 'episode 1 ended'); + await _poll(() => [alice, bob].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), 'the countdown'); + expect(bob.queue.countdown.peek()!.item.id, next.id); + await _poll(() => (bob.serverState.peek()?.mediaId ?? 0) > first, 'episode 3 up', timeout: const Duration(seconds: 20)); + expect(bob.serverState.peek()!.media!['name'], 'Show - 03.mp4'); + expect(bob.serverState.peek()!.media!['swapBy'], alice.connection.memberId, reason: 'who has the file put it up'); + + // Watched through: in the device history of both (alice's by its path, + // bob's by its name and size), recorded as the next one came up. + await _poll(() => alice.watch.history.entry(WatchKey.file(ep1))?.played ?? false, "alice's history"); + await _poll(() => bob.watch.history.entries.any((e) => e.$2.played), "bob's history"); + + // Episode 3 continues with 4; at its end bob cancels: for everyone. + await _poll(() => alice.queue.continues.peek()?.media.name == 'Show - 04.mp4', 'episode 4 next'); + await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5 && x.playback.player.state.playing), + 'both play episode 3', timeout: const Duration(seconds: 20)); + final third = alice.serverState.peek()!.mediaId; + await _poll(() => (alice.queue.countdown.peek()?.secondsLeft ?? 0) > 0, 'the countdown again'); + bob.queue.cancelCountdown(); + await _poll(() => alice.queue.countdown.peek()?.secondsLeft == null && alice.queue.continues.peek() == null, + 'cancelled for alice too'); + await Future.delayed(const Duration(seconds: 12)); + expect(alice.serverState.peek()!.mediaId, third, reason: 'nothing came up'); + expect(alice.serverState.peek()!.transport, 'finished'); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 14d6125..a35fb2a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,9 +7,9 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format: | Path | What | Rule | |---|---|---| -| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter | +| `packages/core` | protocol messages, sync engine (`SyncEngine`, with what's next: `whatsNext`), constants, config, Range parsing, logging, Jellyfin client, updater, the rules every side reads alike (`whats_next.dart`: who puts an Up next item up, who adds the continuation), watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter | | `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter | -| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `MediaPipeline` (opening media), `PlaybackController` (the session player's marks and commands), the player host (`PlayerHost`, `MediaKitHost`: the only media_kit user besides the `Video` widget glue), update UI (`UpdateController`) | ships with `starsync-server` | +| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `MediaPipeline` (opening media), `QueueController` (what's next on this app), `WatchTracker` (watch tracking), `PlaybackController` (the session player's marks and commands), the player host (`PlayerHost`, `MediaKitHost`: the only media_kit user besides the `Video` widget glue), update UI (`UpdateController`) | ships with `starsync-server` | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | | `deploy` | systemd unit for the dedicated server | | | `third_party/media_kit_video` | upstream media_kit_video 2.0.1 with Starsync's patches (`starsync/README.md`: Flutter ≥ 3.38 EGL display on the platform thread, the X11 GLX step-aside) | vendored; re-apply patches on update | @@ -134,10 +134,13 @@ only stopping sharing sends guests out. `AppGraph` (`app/app_graph.dart`) is built once in `main.dart` (`AppGraph.build`) and constructs and disposes the app's systems: the `Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the -`SessionCoordinator`, the connect form and recents (`ConnectStore`, -`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's -matches (`ShowMatcher`, "Library", whose `refForPath` it hands the -coordinator as `fileRef`); it wires them +`SessionCoordinator` (handed the settings and the watch tracker), the +connect form and recents (`ConnectStore`, `state/connect_prefs.dart`), +`YourServer`, `Navigation`, the library's matches (`ShowMatcher`, +"Library", whose `refForPath` it hands the coordinator as `fileRef`, and +a `LocalNextFinder` over it as `nextFile`) and watch tracking +(`WatchTracker` with the device's `WatchHistory`, "Watch tracking"); it +wires them through their constructors (and gives `Profile` the form and the push) and has no logic or signals of its own. Widgets reach it through one `AppScope` InheritedWidget (`AppScope.of(context)`, readable in @@ -301,11 +304,35 @@ Loosely modeled on Syncplay, with a different readiness design. unstable", 9 s is lost (well under the server's 15 s). A welcome that resumed needs nothing re-sent; a new member (grace over, server restarted) gets the app's ready and sitting out again, and the media - opens again only when it changed (or its URL named the old token). The + opens again only when it changed (or its URL named the old token). A + restarted server that has no media while this app had some gets it + back ("Server restart" below). The app's own `ping` every second keeps it under the server's silence limit even with nothing playing. Cancel during setup aborts the join. Leave and closing the window send `bye` (see "App close" under Desktop UI); Update and restart does not. +- **Server restart** (the queue stays in memory, the server stores + nothing): a reconnect that finds another `welcome.instance` with no + media, while this app had media up, holds it: the player plays on and + states without media are not applied (the watch goes on with it, see + "Watch tracking"). One app offers it back (`swap.restore`: the media, + this player's position, whether it played); who goes first is read + from the old state alone, so the apps agree: who put it up or passes + it on after 0.5 s, the others by their old member id from 3 s on, + 1.5 s apart, each only while the server is still empty and only what + it can put up again (`_offerBack`: a Jellyfin item with its own + account, its file, the link it put up, a link everyone opens). The + server keeps the first offer. When it is back as the same media under + a new `mediaId`, the player keeps it unless its URL named the old + token (a relay viewer's `/r/`, the host's `/m/`: opened again), and + what this member passes on is served again (`MediaPipeline.resumeAs`: + its file, its link, its Jellyfin item for viewers without an account); + the watch, the Jellyfin reports and a cleared continuation carry over. + Up next comes back too (`QueueController.restoreAfterRestart`): the + app whose offer won adds the links everyone opens and the Jellyfin + items, every app its own links passed on and files; a server-fetched + link is lost with the old server. Nobody offering within 30 s: the + app lets go of it. - **Auto sit-out** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings "Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and no input for 5 min sets this member `away` (reason `idle`); minimised @@ -1154,29 +1181,19 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, should be) or a track change; Stopped on swap, leave, sitting out, window close (bounded to 1.5 s inside the graceful leave) and at `finished`; seeking back from the end sends Playing again. Reports carry `PositionTicks` - (see "Watched rule"), `IsPaused` and mpv's `aid`/`sid` as Jellyfin + (see "Watched on Jellyfin"), `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. -- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`, - pure, meant for any tracker): each opening samples the position every - tick; only normal playback counts (forward, at most 2x speed + 1.5 s, - samples at most 10 s apart; seeks, skips and pauses add nothing). The - union of the watched stretches is the coverage (rewatching counts once), - alongside the plain watched time, first / last / furthest position and - start / end time. At a stop: played before this opening -> keep played; - coverage >= 70 % of the runtime, or the ending reached (furthest position - past the Outro / ED start or into the last 15 %, at most 4 min, whichever - is earlier) with coverage >= 40 % -> played (someone who watched that - far and stops is done with it); coverage under max(2 min, 5 %) (at most a quarter of a short - item) -> a peek; else part way, resuming at the last position. An - unknown runtime never makes it played. Jellyfin decides played from +- **Watched on Jellyfin**: the watch of the item is the session's (see + "Watch tracking"); `JellyfinProgressReporter` reads its verdict and + never samples itself. Jellyfin decides played from positions alone (10.11): Playing sets a resumable item unplayed, a Progress or Stopped position past "max resume" (90 %) marks it played, - one in between keeps a resume point. So `JellyfinProgressReporter` + one in between keeps a resume point. So the reporter (played before = `UserData.Played` read at open with - `/UserItems/{id}/UserData`): + `/UserItems/{id}/UserData`, handed to the watch with the resume point): - played (before, or by the rule at an earlier stop of this opening): `UserPlayedItems` right after Playing, Progress without `PositionTicks` (leaves the saved position and played state), Stopped @@ -1199,22 +1216,10 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, there is one); from `nextPromptStart` (the ending's start when one is known, see "Skip rules", else the last 2 min of the runtime) a floating "Next episode" pill shows while the controls are up - (see "Player controls"). Auto-advance still waits for `finished`. 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 present members (`in` or `joining`) 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. Only while Up next is empty and its - continuation was not cleared (see "Up next"); a queued item replaces - a countdown already running. + (see "Player controls"). Any of them puts that episode up with the + clicker's account (through Up next when it is the continuation there). + At the end, the next episode continues the item by itself (see "What's + next"). Guests (relay, blocked) see none of it. - **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when hidden from the bar) list `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice` (language, title, codec, channels, forced/HI: from the Jellyfin streams @@ -1227,6 +1232,43 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file through `ServerIsolate.setFile` (host) or the relay (`swapFile`). +## What's next + +One system decides which item plays next and when, and has it prepared +ahead. The server holds the decision (`SyncEngine.whatsNext`, on every +tick and queue change; wire in [PROTOCOL.md](PROTOCOL.md), "Queue"); each +app's `QueueController` (`lib/src/queue/`) adds to the one list, puts an +item up when the server asks it, shows the countdown and keeps the next +item ready ("Getting ready" under "Up next"). + +- **One list**: the queue, Up next's items, then at most one `auto` + item, the continuation of the media playing (`for` its `mediaId`). + Everyone sees it; any swap that does not put it up drops it. +- **The continuation**: added by the app that put the media up (or, when + that member is not `in`, the first member who is: core's + `mayAddContinuation`), when it knows what comes next and the setting + "Play the next episode automatically" is on there: a Jellyfin episode + played with its own account continues with the series' next one + (`adjacentEpisodes`), a file this app put up with the next episode of + its show (`LocalNextFinder`: the library the Files page saw this run, + else the file's own folder scanned and grouped; `LocalShow.nextAfter`, + so the numbers skipped on the way are said in its second line, "skips + 66–107"). It goes up like any item of its kind (a Jellyfin ref, the + adder's file), with a picture, and adds no feed line. A `cancel` + (Clear, or Cancel on the end card) drops it for everyone, and no app + adds it again for that media (the feed's `advance-cancel`). +- **When**: with nothing up, the first item is asked for at once. When + the media finishes with an item first, the server's countdown starts + (`state.advance`, 10 s, the same on every screen); a new first item + takes it over with the same end; a seek, `cancel` (shared) or a swap + ends it. At its end the server sets `queuePlay` and the app that puts + the item up (`queuePerformer`) swaps it in. It holds while that member + is `gone` (in its grace) or nobody is around, or while the item's warm + relay waits for a `gone` source, and asks once they are back. An item + asked for that nobody here can put up (its adder left, nobody has its + Jellyfin server) is skipped by the server (feed `queue-skip`, actor + `server`), and the countdown goes on with the next one. + ## Up next The queue: what plays after the current media. The server holds the list @@ -1249,27 +1291,28 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in and a file go up as a `key` only: the app keeps the URL or path in memory under it and nothing else leaves it. The adder gets a toast "Added … to Up next"; everyone else an activity-log line. -- **Who puts an item up** (`queuePerformer`, from `state` alone, so all - clients agree): a link passed on or a file, only its adder (`gone`: +- **Who puts an item up** (core's `queuePerformer`, from `state` alone, + so the server and every app agree): a link passed on or a file, only its adder (`gone`: the item waits for it through its grace; left for good: skipped, "Bob left"); a Jellyfin item, its adder when it plays that server with its own account, else the lowest present member id that does (`jf`, which lists every working account; nobody: skipped); a link everyone opens or the server fetches, its adder when present, else the lowest present - member id (present: `in` or `joining`, `isPresent` in - `session/party.dart`). A skip is a `remove` with the reason from the - lowest member id that is not `gone` (`queueJanitor`); the feed's - `queue-skip` toasts on every screen. -- **Advancing**: at `finished` with Up next waiting, every screen shows - `UpNextCard` (picture, title, who added it and how; with auto-advance on - a 10 s countdown with Cancel, else Play now only); at zero the app that - puts the first item up does, with a swap naming it (`swap.queueId`), so + member id (present: `in` or `joining`, `isPresentMember`). An item asked + for that nobody can put up is skipped by the server (see "What's + next"); an item whose swap fails is taken off by the app that tried, + with the reason; the feed's `queue-skip` toasts on every screen. +- **Advancing**: at `finished` with something next, every screen shows + `UpNextCard` (picture, title, who added it and how; the server's + countdown with Cancel while `state.advance` runs, "Waiting for Bob" + when it ran out without the one who puts it up, else Play now only; + see "What's next"). The app that puts the item up does when the server + asks (`state.queuePlay`), with a swap naming it (`swap.queueId`), so the server takes it off in the same step and a second swap of it is - ignored. Cancel is per screen like the next-episode countdown. "Play - now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps - directly from the app that can, else sends `play` and that app reacts - to `state.queuePlay`. With nothing up at all, the first item goes up as - soon as it is added. The swap goes through the usual paths + ignored. "Play now" anywhere (a row, the card, the Up next button's ⏭, + `>` / Shift+N) swaps directly from the app that can, else sends `play` + and that app reacts to `state.queuePlay`. With nothing up at all, the + server asks for the first item as soon as it is added. The swap goes through the usual paths (`swapPicked`, `swapJellyfin`; a server-fetched item through `swapQueued`, without the URL, its picture as the art), so relays, art and refusals work as for any swap; when it fails the item is skipped @@ -1278,9 +1321,9 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in out comes back first; any other queued swap leaves the swapper out. - **Getting ready** (`QueueController._warm`): every app keeps the one item that plays next ready all the time, its own copy, so a skip, Play - now or the end of the media starts it at once. That is the first Up - next item, else, with Up next empty, the first "Continues with" - episode (on the apps that have it); while sitting out only the probe + now or the end of the media starts it at once. That is the queue's + first item (Up next's, else the continuation, which is in the queue + for everyone); while sitting out only the probe of a link this app puts up (its swap reads it). Per kind: a Jellyfin item is resolved with this app's account (`resolveJellyfinRef`: PlaybackInfo and the item) into @@ -1346,19 +1389,19 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in says which): hollow waiting, pulsing getting ready (resolving, then preloading its first 4 s), filled ready, red failed (tried again every 2 minutes). -- **Continues with**: not queued, computed here like the next episode: - with an own-account Jellyfin episode playing, the next three episodes - of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next, - "+" adds one. "Clear" hides them for the media playing and keeps its - next-episode countdown from starting. +- **Continues with**: the continuation (the `auto` item, see "What's + next"), greyed under Up next for everyone, ▶ plays it now; "Clear" + drops it for everyone (`cancel`). Under it, on an app playing a + Jellyfin episode with its own account, the series' further episodes + (`/Shows/{id}/Episodes`, cached), "+" adds one to Up next. - **UI**: `UpNextButton` is one of the player's top-bar controls, before the window controls (see "Player overlay" under Desktop UI), in the look of the skin's buttons (`UpNextLook`); it shows and hides with the controls, which an open popover holds up: the first item's picture (`QueueThumb`, the rows' faint box and icon until it arrives), "UP NEXT" over its title, a +n count, ⏭ plays it now; without Up next the continuation - (the episode's image, "NEXT · S1 E8" over its name, ⏭ = next episode); - nothing when both are empty. Resting on it 600 ms (popover closed) + (its picture, "NEXT · S1E8" (its second line) over its name, ⏭ plays + it); nothing when both are empty. Resting on it 600 ms (popover closed) shows `UpNextPreview` under it, right edges aligned, in `AppGlass`: the picture large, title, second line, "length · added by (avatar) name · how" (an episode: series, length, overview), how ready it is @@ -1464,7 +1507,54 @@ show or movie, `ProviderIds` in the item fields; from a pick's lines, title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds carry it; Up next names a file item by it. Consumers: receivers' now playing title and episode line (`refTitle`), the swapper's art (by the -AniList id); later the watch tracking keys on it. +AniList id), the watch tracking's records (see "Watch tracking"). + +## Watch tracking + +What this device watched, by D3: watched means at least as good as +Jellyfin's own, and a peek, skipping around or a screenshot visit never +counts. The truth is split: the device holds its watch history, Jellyfin +its played state and resume point (reported now), AniList its list +(later). + +- **The rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`, + pure): one per opening, sampled every second with the position, + whether the media plays here (the server's transport is playing and + this player plays it) and whether this viewer is In (its presence in + `state`). The basis is time In: wall time it played while this viewer + was `in`. Time sat out (Away), joining, gone, paused or held never + counts, nor does a jump (skipping around adds only what was played); + samples more than 10 s apart add nothing. A resume carries the earlier + watch: when what played starts within 60 s of the resume point, the + time up to it counts too. At a stop: played before -> keep played; + time In >= 70 % of the runtime, or the ending reached (furthest + position while In past the Outro / ED start or into the last 15 %, at + most 4 min, whichever is earlier) with time In >= 40 % -> played; + under max(2 min, 5 %) (at most a quarter of a short item) -> a peek; + else part way, resuming at the last position seen while In. An unknown + runtime never makes it played. +- **The tracker** (`WatchTracker`, `lib/src/watch/`, made by the + composition root): one watch per media of the server (`mediaId`), fed + by the coordinator's 1 s tick, also through drops (the player goes + on); a reconnect or a server restart never ends it while the media + plays on (it follows the media to its new `mediaId`, and goes on while + a restarted server waits for it, see "Server restart"); another media + or leaving closes it, the end of the media + (`finished`) records it as it stands (a seek back goes on with it). + Each record (`WatchRecord`: key, title, `media.ref`, verdict, time In, + runtime) goes out on `records`, where AniList progress will listen, + and to the device's history. Keys (`WatchKey`): a file this app put up + by its path, a Jellyfin item by its ids, a link this app put up or + everyone opens by its URL, else name and size (someone else's file). +- **The history** (`WatchHistory`, Hive box `watch_history`): files and + links only (Jellyfin keeps its own): played, or a resume point (a part + way watch of an item played before keeps it played); a peek changes + nothing. Played before (and the resume point) for the next opening + come from it; the Files page's "Next" follows the newest watched + episode of a show, else the last one opened (Recent). +- **Jellyfin**: the reporter reads the same watch (see "Jellyfin in a + session", "Watched on Jellyfin"); Jellyfin's played state and resume + point read at open become the watch's "before". ## App events and platform ports @@ -1811,7 +1901,8 @@ panel) and chat messages (Copy message, and a link in it). servers), player (the shared rows below, the controls' choices, custom shader files), Jellyfin & integrations (linked servers, AnimeFlow's `/me` via - `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds + `AnimeFlowClient.fromAccounts`, "Play the next episode automatically": + whether this app adds the continuation, see "What's next"), notifications & sounds (what pops up, chat pop-ups, joins and leaves while sitting out or in the background), updates, shortcuts, about. - **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one @@ -1928,8 +2019,8 @@ panel) and chat messages (Copy message, and a link in it). 16 px from the bottom while they are hidden (`AnimatedPositioned`, the shell's `ease` / `easeTime`); in it the status pill centred (moved left only as far as it must to clear the right end, never - overlapping it) and at the right end the end card (`UpNextCard`, else - `EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED / + overlapping it) and at the right end the end card (`UpNextCard`, which + stays up) or the action pills (Skip OP / ED / 1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in the middle. Two surfaces: cards and popovers use `AppGlass` (`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline @@ -2064,8 +2155,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins. volume, skip offer, render mode; the shell's fullscreen, chat open and unread count) and what they do (`togglePlay` = ready, as Space; `seek` through `player.seek`, the user path; `nudge`, `skipForward`, - `skip`, `stepChapter`, `pickChapter`; `next` = Up next's first item, - else the next episode, `previous`; volume set / step (flashes) / mute; + `skip`, `stepChapter`, `pickChapter`; `next` = the queue's first item + (Up next's, else the continuation), else the next episode, `previous`; volume set / step (flashes) / mute; `pickTrack`, `pickShader`; time display; fullscreen, chat, change video, collapse, leave, reload, mark everyone ready, link). The player keys call the same actions. `ControlsSnapshot` (computed) is diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index aaaec65..6fbdf0f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -84,15 +84,18 @@ Three suites, run from each package's folder: what a player opens for a page (`YtDlpPlay`) and the cookie notes of a failed resolve against a stand-in yt-dlp (`cookies_test`, D11), the sync engine (clock, gate, presence and grace, correction, end of - file), the `PresencePolicy` table, queue model, the library's L1 + file), the `PresencePolicy` table, queue model and what's next (who + puts an item up, the countdown on a fake clock), the library's L1 (release and folder names, `groupLocal` over a real anime folder's names: shows across folders, holes, extras, a movie), - relay pieces, updater, logging redaction, watch rule, yt-dlp and + relay pieces, updater, logging redaction, the watched rule (time In), + yt-dlp and Jellyfin client parsing against captured fixtures. - `cd packages/server && dart test` (well under a minute): live servers on loopback: auth and version refusal, identity, takeover and grace (`presence_test` on a fake clock), media and relay byte serving with - source drops, Jellyfin relay against a fake, queue, session log, + source drops, Jellyfin relay against a fake, queue (and the shared + countdown on a fake clock), session log, isolate `serve`, sharing (`share_test`: a listener attached and detached under the same members and Up next, the host key standing in for the password). @@ -101,7 +104,9 @@ Three suites, run from each package's folder: fake AniList (`show_matcher_test`), `media.ref` producers, shader list, sound engine with a fake backend, invite addresses, the version gate, the media plan's table with the Jellyfin relay rule and - the "Waiting for" status (`media_plan_test`)), `singletons_test` (the `.instance` count, see + the "Waiting for" status (`media_plan_test`), watch tracking and the + device history (`watch_tracker_test`), what continues a file of a + local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` tests plus `coord_presence`. The whole suite takes a couple of minutes; run it only before a restructure step lands, otherwise only the files @@ -114,7 +119,9 @@ else is offline: Jellyfin and link hosts are local fakes. Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a `FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`, -a dead or restarted server), `video_reload_e2e` (a fresh player from the +a dead or restarted server; a restarted server that came back empty gets +what played back at its position, a link everyone opens and a file +passed on, nothing reopened, the watch and Up next carrying on), `video_reload_e2e` (a fresh player from the host, the old one retired, leave releases them all), `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`, `ytdl_headers_e2e` (a page through the app's own resolve against a @@ -126,7 +133,11 @@ own: a guest's file, links passed on or fetched by the server, a late source, a source that drops and resumes; a seek past what a slow source has sent, timed; a source that drops while a viewer seeks past the buffer and another joins: no seek loop, "Waiting for", all resume -(D6)), `jellyfin_e2e`, `queue_switch_e2e` (times Up next switches +(D6)), `jellyfin_e2e` (account holders report what they watched, one +who sat out is not marked watched, the next episode continues for +everyone), `whats_next_e2e` (a file of a show continued by its next +episode with the gap said, the server's countdown on both screens, a +shared cancel, the watch history), `queue_switch_e2e` (times Up next switches through the media pipeline against a delayed, rate-capped fake Jellyfin and link host: an episode, a direct link, a link passed on and a server-fetched one, each prepared and promoted; no mpv "error running @@ -179,11 +190,11 @@ in [ROADMAP.md](ROADMAP.md)): | Scenario | Covered today | Missing, added in | |---|---|---| -| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | | +| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again, and what played if it came back empty), server `queue_test` (the first restore offer wins), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | | | Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | | -| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) | +| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out); core `watch_session_test`, desktop `watch_tracker_test`, `jellyfin_session_test` "sat out", `jellyfin_e2e` (sat out: not tracked as watched, P7) | | | Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e`, `media_switch_e2e` (no command reaches a player mid-switch) | | -| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` (prepared and promoted through the media pipeline) | step 6: the countdown as server state (`state.advance`) | +| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`; the countdown: start, follow the first item, seek, cancel, hold for a gone performer or source, skip what nobody can put up), `whats_next_test`; server `queue_test` (the countdown and cancel shared, a skip by the server); `relay_test` "warm relay"; desktop `jellyfin_session_test` (end card), `jellyfin_e2e` and `whats_next_e2e` (the continuation and the countdown end to end), `queue_switch_e2e` (prepared and promoted through the media pipeline) | | | Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`), `video_reload_e2e` (leave releases every player); the close smoke run on the built app (`tool/smoke_windows.ps1` in CI, `tool/smoke_linux.sh` by hand) | | A test file whose first line is `// restructure: rewrite in step N` pins diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index f9105ac..14d1100 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -78,7 +78,13 @@ brings you back. 6. **Swap.** Anyone can change the video by pasting another link. Everybody stops at 0 and the gate runs again for everyone there; ready flags stay as they were (a setting unreadies you instead). -7. **Leave.** Closing the app or Leave says goodbye and drops you from the +7. **What's next.** Up next is one list on the server for everyone, and + an episode you put up is followed by its next one. When a video ends + with something next, the server counts down 10 s on every screen; + anyone can cancel it for everyone (a seek stops it too). At zero the + app that has what plays next puts it up; if that person is away for a + moment, the countdown waits for them. +8. **Leave.** Closing the app or Leave says goodbye and drops you from the server; the others keep going. If you vanish without a goodbye, after 15 s of silence you are "gone": you stop blocking but keep your place (ready flag, sitting out, Up next items, the video you pass on) for @@ -90,6 +96,11 @@ anyone, and sit anyone out (the host too). Someone sitting out stays connected and in the chat, but their app loads nothing and nobody waits for them until they join back. +Watched or not is counted by the time you were there while it played: +sitting out, skipping around or a quick look never counts as watching. +Jellyfin hears it for your account; files and links are remembered on +your computer. + ## Where video comes from - **Link**: you paste an http(s) URL. By default your computer opens it and diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 5743ed7..e3f1f9b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -98,6 +98,7 @@ yet; the restructure step named next to them does. {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"swap","media":{...},"queueId":"q3"} puts up an Up next item +{"t":"swap","media":{...},"restore":{"position":512.3,"transport":"playing"}} puts back what played (see below) {"t":"report","position":123.1,"buffered":6.2,"eventId":41, "duration":1440.5,"mediaId":2,"jf":{"":true},"error":"Not allowed (403)"} {"t":"ping","c":1767225600000} @@ -205,6 +206,13 @@ Field notes: every member's `ready` and puts every member in the set (`inSet`; who counts is up to presence); clients that want to be unready for the new media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate"). +- `swap.restore`: what played before the server restarted, offered back + by a member that still holds it (`position` seconds, `transport` + `playing`, `held` or `finished` as it was). The server takes it only + while it has no media; any later one is ignored, so the first offer + wins. It is a swap otherwise (relays, art, the gate), held at + `position` (`finished`: at the end), its feed `swap` line with + `reason` `restore`. - Frames before `welcome` (other than `hello`) are ignored; unparsable or unknown frames are ignored silently. Frames from a socket another hello took over are ignored. @@ -282,8 +290,8 @@ Field notes: `source` (the same while the relay's source is `gone`), `start` (nobody counts for the gate: someone has to ready up). `members`: the member ids the gate waits for (empty for `start`). -- `state.advance` (**reserved**, step 6): the shared countdown to Up next - (see "Queue"). +- `state.advance` (omitted when none runs): the shared countdown to Up + next's first item at the end of the media (see "Queue"). - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms (the clock `serverTime` uses). With `t3` the local receive time, the offset is `s - (c + t3) / 2`, accurate to half the round trip. @@ -299,9 +307,10 @@ Field notes: the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`, - `queue-skip`, `advance-cancel` (`target` the dropped continuation's - title, if any), `fetch-fail` (the server could not fetch the current - media, `target` its name). `reason` carries `auto`/`manual` for ready. + `queue-skip` (actor `server` when the server skipped it), `advance-cancel` + (`target` the dropped continuation's title, if any), `fetch-fail` (the server could not fetch the current + media, `target` its name). `reason` carries `auto`/`manual` for ready, + `restore` for a swap that put back what played (`swap.restore`). `rename`: `actor` is the new name, `target` the old one. `avatar`: the actor changed only the picture. `queue-*`: `target` is the item's title, `reason` (skip) why it could not play (see "Queue"). @@ -623,7 +632,7 @@ state "queue":[,...], "queuePlay":"q3" = {"id":"q3","media":{},"how":"direct"|"server"|"through"|"file"|"jellyfin", "title":"...","sub":"...","duration":213,"thumb":"", "by":"m2","byName":"Bob","key":"","auto":true?,"for":2?} -state "advance":{"id":"q3","at":"","secs":10} reserved +state "advance":{"id":"q3","at":"","secs":10} ``` - `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets @@ -634,7 +643,8 @@ state "advance":{"id":"q3","at":"","secs":10} reserved item is queued. `title` and `sub` are cleaned like media names and cut to 200 characters (an empty title becomes the media's name); `duration` (seconds, omitted when unknown) is informational. At most 100 items; - over that the sender gets `err` `Up next is full (100 items)`. + over that the sender gets `err` `Up next is full (100 items)`. With + nothing up, the first item is asked for at once (`queuePlay`). - `how` and what `media` must carry: - `direct`: an http(s) `url`; everyone opens the link. - `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server @@ -654,7 +664,10 @@ state "advance":{"id":"q3","at":"","secs":10} reserved - `play`: moves `id` first and sets `state.queuePlay` to it: a request for the member who can put it up to do so now. `skip` is `play` of the first item. `queuePlay` ends with any swap, when the item is removed, - or after 30 s. + or after 30 s. An item asked for that nobody here can put up (its + adder left for good, nobody has its Jellyfin server; the rule is + core's `queuePerformer`, ARCHITECTURE "What's next") is taken off by the + server: feed `queue-skip`, actor `server`, `reason` why. - `auto` (with `for`, the `mediaId` it continues): the continuation (the next episode). Only for the current media (else `err` `bad`), and only from the member who put it up (`media.swapBy`) or, when that member is @@ -662,12 +675,19 @@ state "advance":{"id":"q3","at":"","secs":10} reserved one: a newer one replaces it; it stays after the other items (an `add` goes before it, `next` puts an item first, `move` never passes it, it does not move itself); any swap that does not put it up drops it. + Adding it gives no feed line. - `cancel`: drops the `auto` item and the countdown for everyone (feed `advance-cancel`); nothing to drop, nothing happens. -- `state.advance` (**reserved**, step 6): the shared countdown when the - media finishes and an item is first; at `at` + `secs` the server would - set `queuePlay` to `id`, holding while its performer or source is - `gone`. The server never starts one yet; `cancel` and any swap clear it. +- `state.advance`: the shared countdown, started when the media finishes + with an item first (or an item comes first while it is finished); + `secs` is 10, `at` its start (server clock). A new first item takes it + over with the same end. At `at` + `secs` the server sets `queuePlay` to + `id` and the countdown is over; while the member who puts the item up + is not around (its adder `gone`, nobody present) or the item's warm + relay waits for a `gone` source, it holds, and asks once they are + back. A seek (the end of `finished`), an empty queue, `cancel` and any + swap clear it; after `cancel` it does not start again for that media, + nor again for the item it already asked for. - `swap.queueId`: the swap puts that item up and takes it off the queue in the same step. A swap naming an item that is no longer queued is ignored (someone put it up first). For a `server` item the swap may diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a0c5e19..94bb2f6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,8 +35,6 @@ Problems are numbered; delete a line when its fix ships. - D11 Login-only sites: error texts say which cookies were used. **Problems:** -- P7 Sat-out people get tracked as watched on Jellyfin. -- P9 The watched rule (position coverage) misses real watches (D3). - P25 ssh to Gitea blocked through the proxy (pushes go over https). - P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back check; Windows hwdec `auto-copy`): a Windows user must confirm shaders @@ -61,16 +59,12 @@ the automatic continuation and countdown, relay state), the device (client id, library and folders, match corrections, watch history, settings, notification prefs, what this app is sourcing) and the integrations (Jellyfin played/resume, AniList list). -6. What's next (decides which/when, asks the pipeline to prepare; - continuation and countdown as server state, shared cancel) + Watch - tracking (time In from the presence history, device history, Jellyfin - now, AniList later). Fixes P7 P9; D3. 7. Release 1.3.0: publish the manifest (protocol 16 makes it a required update for 1.2.x) and update the VPS dedicated server in the same window; check a 1.2.x app gets a clear "update the app" message. Composition root (in place, ARCHITECTURE "Composition root"): the remaining `.instance` singletons move into `AppGraph` as the steps touch -them; `singletons_test` holds the count (today: AppSettings 55, +them; `singletons_test` holds the count (today: AppSettings 49, JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6, AppYtDlp 2, RecentLinks 3). Not in the restructure: P25 (ops). @@ -113,6 +107,30 @@ your server leaves them there without you, unwarned; a join refused after dialling (wrong password) costs your own member there (`bye`, back as a new member). Your server writes its session log even when you are alone. +Left of step 6 (what's next, watch tracking): a human check, on Linux +now, of the server's countdown and the end card in real use (Cancel +from another screen, a seek stopping it, "Waiting for" while the one who +puts it up is gone), of a file continuation on the real folder (Bleach +"skips 66–107" across its folders, SAO S3 into its OP/ED folder, the +last episode of a show) and of Jellyfin's played state and resume point +under time In against a real Jellyfin (a full watch, one sat out, a +resume), and of a dev server restarted mid-episode (what played comes +back where it was for everyone, a Jellyfin item passed on to a guest +re-fed: only files and links are covered by tests). Not built: AniList +progress (the tracker's `records` are its seam); nothing resumes a file +or link at the history's resume point (it only keeps it); no watched +marks on Files' episode rows (only "Next" follows the history) and no +way to see or clear the history. The continuation of a file comes only +from the app that put it up (only it has the file). "Play the next episode +automatically" is the setting of whoever puts an episode up; Up next's +own items count down whatever it says. A play request the performer +does not act on ends after 30 s without a new countdown for that item +(Play now remains). A warm relay waiting for a `gone` source holds the +countdown even when the one who puts the item up is there. After a +server restart, a relayed file or link whose member is not back, or a +Jellyfin item nobody back has an account for, cannot be put back (the +apps let go after 30 s); a server-fetched Up next item is lost (its +address stayed with the old server). Left of step 5 (media pipeline): a human check, on Linux now and on Windows at 1.3.0, of a page link in "Everyone fetches it" through the app's own resolve (YouTube's 1080p video + audio EDL, Bilibili with its @@ -131,6 +149,14 @@ CDN wants per-track headers that differ gets the first track's (as mpv's hook did); Bilibili's danmaku is dropped, not shown. The Jellyfin item this member put up re-feeds its relay after an app restart (the state still names it the source). +Relay start (P29's rest): a seek into what the source has not sent +plays after about 2.8 s in the e2e; a human check of a real session +on dev.8+. The source's in-flight window still starts at 256 KiB and +grows with the measured rate, so the speed climbs from low after a +seek or a new link; the start of a freshly put-up link was never +measured on its own. Levers if it still shows: start the window from +the last rate, prefetch from the source as soon as a link goes up, +a smaller gate buffer when only the relay is behind. Left of step 4 (library): a human check of the Files library on the real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the @@ -142,9 +168,7 @@ swapper's bytes (now chosen by the ref's AniList id). Relay logins are sent at Invite: an account linked or signed in again later relays only after Stop sharing and Invite. "Season N" without a name walks AniList's sequels, which can land on the wrong cour where AniList splits seasons; -Wrong show? fixes it. The next episode of a local show lives on its page -(from Recent); no countdown or "Continues with" for files yet (backlog -2). The `library` box keeps every AniList entry seen (no pruning); +Wrong show? fixes it. The `library` box keeps every AniList entry seen (no pruning); AniList's episode titles exist only where it lists streaming episodes. Left of step 2: system notifications need a human check (Linux: the notification shows and a click brings the window up, also on Wayland, @@ -170,13 +194,8 @@ Opened in step 1: [PROTOCOL.md](PROTOCOL.md) is proto 16 (version ranges and the refusal frame, error codes and close codes, lasting members by client id with takeover and `seq`, grace and `bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and replay, relay `sourceGone` and source resume, lasting `queue[].by`, -`auto` items and `cancel`, `media.ref`, `members[].app`/`caps`), server, +`auto` items, `cancel` and `state.advance`, `media.ref`, `members[].app`/`caps`), server, core and app. Still open on the wire, frozen at the 1.3.0 release: -- `state.advance` (step 6): the server starts the countdown when the - media finishes and an item is first, sets `queuePlay` at its end and - holds it while the item's performer or source is `gone`. Today it is - parsed and broadcast but never started; `auto` items and `cancel` are - live. - `hello.client` optionally derived per server (HMAC of an install secret and the dialled host:port). - TLS: `/health.tls` is always false; `wss://` only with a CA-valid @@ -207,12 +226,7 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: seeks are shared, and only real ranges (segments, OP/ED chapters) should ever be skipped. -2. **Files**: the next episode of a local show (the library's - `nextAfter`, gaps included) at the end of one, with the countdown, and - under Up next's "Continues with". Tracking for file series comes with - step 6. - -3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the +2. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the bundled Windows libmpv plays the app's `edl://` URLs (video + audio with headers) through media_kit. YouTube: yt-dlp wants a JS runtime (deno) for full extraction. @@ -229,15 +243,15 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: - Thumbnails and scrubbing previews of a relayed media with tracks should open the video track's URL alone, not the EDL. -4. **AniList account**: sign in (OAuth via a localhost callback), "My list" +3. **AniList account**: sign in (OAuth via a localhost callback), "My list" filter on the Schedule from the user's AniList lists. This is the Schedule's personal tracking; AnimeFlow has no per-user follow. -5. **Ops**: Check shaders on Windows; run the relay e2e "the source drops +4. **Ops**: Check shaders on Windows; run the relay e2e "the source drops uncleanly and comes back" a few times under the full suite (its predecessor failed about half the time there). -6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x +5. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x install updates and restarts (`watch_together.exe`, a copy of `starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from `%APPDATA%\com.example\watch_together` and Documents, the next update @@ -247,7 +261,7 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: fullscreen controls, hosting (firewall prompt), in-app update from one release to the next. The rest of the desktop tests are not in CI. -7. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's +6. **Relay follow-ups** (see ARCHITECTURE "Relay"). First real use: owner's PC -> VPS -> friend, Jellyfin and a file. Then: - Source takeover: when the source leaves, another member with an account on that Jellyfin (or the same file: name, size, hash) takes @@ -268,9 +282,9 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: fetch of member-resolved links (the member need not upload or stay) is worth building. -8. **Jellyfin** (epic, below). +7. **Jellyfin** (epic, below). -9. **Smaller items** +8. **Smaller items** - Video output robustness: if media_kit's hardware path regresses to software (see ARCHITECTURE, Media: render mode), vendor a media_kit_video fix that builds the EGL display from GDK's native @@ -298,10 +312,6 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin in a session"): -- Auto-advance cancel for everyone (the next episode and Up next): today - Cancel is per screen (the designated swapper's Cancel stops the swap; a - seek stops all). The wire has it (`queue` `cancel`, `state.advance`); - the app uses it in step 6. - Sidecar (external) subtitle streams of Jellyfin items: only embedded tracks are mapped to mpv today. diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 8d5cc43..c932228 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -16,6 +16,7 @@ export 'src/media_source.dart'; export 'src/sync_engine.dart'; export 'src/updater.dart'; export 'src/watch_session.dart'; +export 'src/whats_next.dart'; export 'src/jellyfin/client.dart'; export 'src/jellyfin/client_info.dart'; export 'src/jellyfin/http.dart'; diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index f4a6bf2..a1aff6d 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -92,10 +92,12 @@ abstract class RelayConstants { /// Up next (`queue`): at most this many items; a title or second line /// is cut to [maxQueueTextLength] characters; an item's picture is /// capped like a poster ([maxPosterBytes]). A play request nobody acts on - /// ends after [queuePlayTimeoutSecs]. + /// ends after [queuePlayTimeoutSecs]. The countdown to the first item at + /// the end of the media (`state.advance`) runs [advanceSecs]. static const int maxQueueItems = 100; static const int maxQueueTextLength = 200; static const int queuePlayTimeoutSecs = 30; + static const int advanceSecs = 10; /// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's /// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index f850283..071e68f 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -836,16 +836,42 @@ class SeekIntent { } } +/// `swap.restore`: what played before the server restarted, offered back +/// by an app that still holds it: at [position] (seconds), [transport] +/// as it was (`playing`, `held` or `finished`). The server takes it only +/// while it has no media (the first offer wins, later ones are ignored). +class SwapRestore { + final double position; + final String transport; + const SwapRestore({required this.position, this.transport = 'held'}); + + Map toJson() => {'position': position, 'transport': transport}; + + static SwapRestore? fromJson(Object? j) { + if (j is! Map) return null; + final t = j['transport']; + return SwapRestore( + position: finiteDouble(j['position'], 'restore.position'), + transport: t == 'playing' || t == 'finished' ? t as String : 'held'); + } +} + class SwapIntent { final HelloMedia media; /// The Up next item this swap puts up (`queue[].id`); the server takes /// it out of the queue with the swap, and ignores the swap when it is no /// longer queued (someone else put it up first). final String? queueId; - const SwapIntent({required this.media, this.queueId}); + /// Puts back what played before a server restart ([SwapRestore]). + final SwapRestore? restore; + const SwapIntent({required this.media, this.queueId, this.restore}); - Map toJson() => - {'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId}; + Map toJson() => { + 't': 'swap', + 'media': media.toJson(), + if (queueId != null) 'queueId': queueId, + if (restore != null) 'restore': restore!.toJson(), + }; factory SwapIntent.fromJson(Map json) { if (json['t'] != 'swap') throw const ProtocolException('not a swap intent'); @@ -854,7 +880,8 @@ class SwapIntent { final queueId = json['queueId']; return SwapIntent( media: HelloMedia.fromJson(Map.from(media)), - queueId: queueId is String && queueId.isNotEmpty ? queueId : null); + queueId: queueId is String && queueId.isNotEmpty ? queueId : null, + restore: SwapRestore.fromJson(json['restore'])); } } @@ -1147,11 +1174,11 @@ class HoldInfo { } } -/// `state.advance` (reserved, step 6): the shared countdown to Up next's -/// first item when the media finishes. At [at] + [secs] (server clock) the -/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears -/// it for everyone. The server parses and broadcasts it but never starts -/// one yet. +/// `state.advance`: the shared countdown to Up next's first item when the +/// media finishes. At [at] + [secs] (server clock) the server sets +/// `queuePlay` to [id] (holding while its performer or its relay's source +/// is `gone`); `{"t":"queue","op":"cancel"}` and a seek clear it for +/// everyone (see `SyncEngine.whatsNext`). class QueueAdvance { final String id; final DateTime at; @@ -1211,7 +1238,7 @@ class StateBroadcast { final String? queuePlay; /// Why it is held ([HoldInfo]); null unless `held` with media up. final HoldInfo? hold; - /// The shared countdown to Up next (reserved, [QueueAdvance]). + /// The shared countdown to Up next's first item ([QueueAdvance]); null when none runs. final QueueAdvance? advance; const StateBroadcast({ required this.eventId, diff --git a/packages/core/lib/src/queue.dart b/packages/core/lib/src/queue.dart index d927d26..aa7db81 100644 --- a/packages/core/lib/src/queue.dart +++ b/packages/core/lib/src/queue.dart @@ -2,9 +2,9 @@ import 'dart:typed_data'; import 'constants.dart'; import 'errors.dart'; -import 'presence.dart'; import 'protocol.dart'; import 'sync_engine.dart'; +import 'whats_next.dart'; // Up next: what plays after the current media, held by the server for // everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the @@ -342,14 +342,15 @@ typedef QueueRefusal = ({ErrCode code, String message}); /// The `queue` messages applied to the engine's [SyncEngine.queue], with /// their feed lines (`queue-add`, `queue-remove`, `queue-skip`, -/// `advance-cancel`). Each returns whether the queue changed; -/// [applyQueueAdd] a refusal for the sender instead (null when added). +/// `advance-cancel`), then what's next ([SyncEngine.whatsNext]). Each +/// returns whether the queue changed; [applyQueueAdd] a refusal for the +/// sender instead (null when added). extension EngineQueueOps on SyncEngine { /// [item] is already cleaned by the server (see `cleanQueueAdd`). An /// [QueueItem.auto] item must continue the current media /// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put - /// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the - /// first member who is. + /// it up or, when that member is not `in`, from the first member who is + /// ([mayAddContinuation]); it adds no feed line (it is no news). QueueRefusal? applyQueueAdd(String token, QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) { final member = members[token]; @@ -358,24 +359,27 @@ extension EngineQueueOps on SyncEngine { if (media == null || item.forMedia != mediaId) { return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media'); } - final swapper = members[swapBy]; - final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull; - final allowed = token == swapBy || - ((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member)); - if (!allowed) { + final views = [for (final m in members.values) m.view(inGate: false)]; + if (!mayAddContinuation(member.id, members[swapBy]?.id, views)) { return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it'); } } final added = queue.add(item.copyWith(by: member.id, byName: member.name), next: next, privateUrl: privateUrl, thumb: thumb); if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)'); - feed.add(FeedMessage( - actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now)); + if (!added.auto) { + feed.add(FeedMessage( + actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now)); + } + whatsNext(); return null; } - bool applyQueueMove(String token, String id, int to) => - members.containsKey(token) && queue.move(id, to); + bool applyQueueMove(String token, String id, int to) { + if (!members.containsKey(token) || !queue.move(id, to)) return false; + whatsNext(); + return true; + } /// [why] set: skipped because it cannot play (`queue-skip`, with the /// reason), else removed by hand (`queue-remove`). @@ -391,6 +395,7 @@ extension EngineQueueOps on SyncEngine { target: gone.title, reason: why, at: now)); + whatsNext(); return true; } @@ -399,17 +404,20 @@ extension EngineQueueOps on SyncEngine { if (!members.containsKey(token)) return false; final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id; if (target == null || queue.play == target) return false; - return queue.requestPlay(target, now); + if (!queue.requestPlay(target, now)) return false; + whatsNext(); + return true; } /// `cancel`: drops the continuation ([EngineQueue.auto]) and the - /// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`). + /// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`); + /// no countdown starts again for this media. bool applyQueueCancel(String token) { final member = members[token]; if (member == null) return false; final dropped = queue.dropAuto(); final hadAdvance = advance != null; - advance = null; + cancelAdvance(); if (dropped == null && !hadAdvance) return false; feed.add(FeedMessage( actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now)); diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index ecb80b6..b85f665 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -7,6 +7,7 @@ import 'constants.dart'; import 'presence.dart'; import 'protocol.dart'; import 'queue.dart'; +import 'whats_next.dart'; typedef SyncClock = DateTime Function(); @@ -245,9 +246,19 @@ class SyncEngine { /// Up next ([EngineQueue]): in `state.queue`, edited by `queue` /// messages, taken from by swaps that name an item. final EngineQueue queue = EngineQueue(); - /// The shared countdown to Up next (`state.advance`); reserved: only - /// `cancel` touches it today. + /// The shared countdown to Up next's first item at the end of the media + /// (`state.advance`, see [whatsNext]). QueueAdvance? advance; + /// The relay of Up next item `queueId` waits for its source, who is + /// `gone` (set by the server: the engine does not hold relays). The + /// countdown holds while it does. + bool Function(String queueId)? itemSourceGone; + // The mediaId whose countdown was cancelled (`cancel`): it does not + // start again for that media. + int _advanceOffFor = -1; + // The item last asked to play for the current media: not counted down + // again. + String? _advancedId; /// A member went `gone` (its socket closed or fell silent): the server /// closes the socket and marks a relay it serves. void Function(EngineMember member)? onGone; @@ -316,12 +327,97 @@ class SyncEngine { at: now); /// Heartbeat (1 s): silence, joining timeouts, gone notices, grace, - /// a stale play request, end of media. + /// a stale play request, end of media, what's next. void tick() { sweepSilent(); sweepPresence(); queue.tick(now); evaluate(); + whatsNext(); + } + + /// What's next (PROTOCOL.md "Queue"), on every tick and queue change: + /// - a play request (`queuePlay`) nobody here can serve is skipped + /// (feed `queue-skip`, actor `server`, with why: [queuePerformer]); + /// - with nothing up, the first item is asked to play at once; + /// - at the end of the media with an item first, the countdown + /// ([advance], [RelayConstants.advanceSecs]) starts (not again for a + /// media whose countdown was cancelled, nor for the item it already + /// asked for); it follows the first item when that changes, keeping + /// its end; it ends with the end of `finished` (a seek), an empty + /// queue, `cancel` or a swap. At its end the first item is asked to + /// play (`queuePlay`) and the countdown is over, unless nobody can + /// put it up (skipped) or its performer is not around or its relay's + /// source is `gone` ([itemSourceGone]): then it holds until they are + /// back. + /// Returns whether anything changed. + bool whatsNext() { + var changed = false; + final views = [for (final m in members.values) m.view(inGate: false)]; + final play = queue.play; + if (play != null) { + final item = queue.item(play); + final why = item == null ? null : queuePerformer(item, views).why; + if (item != null && why != null) { + _skipQueued(item, why); + changed = true; + } + } + final first = queue.items.firstOrNull; + if (media == null) { + if (first != null && queue.play == null) changed = _due(first, views) || changed; + return changed; + } + var a = advance; + if (a != null && (transport != Transport.finished || first == null || queue.play != null)) { + advance = a = null; + changed = true; + } + if (transport != Transport.finished || first == null || queue.play != null) return changed; + if (a == null) { + if (_advanceOffFor == mediaId || _advancedId == first.id) return changed; + advance = a = QueueAdvance(id: first.id, at: now, secs: RelayConstants.advanceSecs); + changed = true; + } else if (a.id != first.id) { + advance = a = QueueAdvance(id: first.id, at: a.at, secs: a.secs); + changed = true; + } + if (now.isBefore(a.at.add(Duration(seconds: a.secs)))) return changed; + return _due(first, views) || changed; + } + + /// [item] should play now: asked for (`queuePlay`), skipped when nobody + /// can put it up, or held. Returns whether anything changed. + bool _due(QueueItem item, List views) { + final perf = queuePerformer(item, views); + final why = perf.why; + if (why != null) { + _skipQueued(item, why); + return true; + } + if (perf.id == null || (itemSourceGone?.call(item.id) ?? false)) return false; + queue.requestPlay(item.id, now); + advance = null; + _advancedId = item.id; + return true; + } + + void _skipQueued(QueueItem item, String why) { + queue.remove(item.id); + // The countdown goes on with the next item (asked for on the next + // tick when its time is up). + final a = advance, first = queue.items.firstOrNull; + if (a != null && a.id == item.id) { + advance = first == null ? null : QueueAdvance(id: first.id, at: a.at, secs: a.secs); + } + feed.add(FeedMessage( + actor: 'server', actorId: 'server', action: 'queue-skip', target: item.title, reason: why, at: now)); + } + + /// `cancel` for the current media: no countdown for it any more. + void cancelAdvance() { + advance = null; + _advanceOffFor = mediaId; } /// Joining: loads in the background until its reports hold the media @@ -617,6 +713,8 @@ class SyncEngine { _seekSeq = seq; _seekEventId = eventId; feed.add(_line('seek', actor: member)); + // A seek stops the countdown to Up next. + advance = null; // Seeking back from the end: held, and resumes once the gate passes. if (transport == Transport.finished) { transport = Transport.held; @@ -627,11 +725,15 @@ class SyncEngine { /// Returns false (no state change) for media with non-finite numbers. /// [queueId]: the Up next item this swap puts up; it leaves the queue /// (any other swap drops the continuation item, [EngineQueue.auto]). - bool applySwap(String actorToken, HelloMedia next, {String? queueId}) { + /// [restore]: what played before a restart, put back where it was (the + /// server takes it only while nothing is up; see `swap.restore`): held + /// at its position (the gate starts it again), or finished. + bool applySwap(String actorToken, HelloMedia next, {String? queueId, SwapRestore? restore}) { if (!next.duration.isFinite || next.size < 0) return false; final actor = members[actorToken]; queue.swapped(queueId); advance = null; + _advancedId = null; mediaId++; media = next; swapBy = actorToken; @@ -648,7 +750,15 @@ class SyncEngine { member.inSet = true; member.buffered = 0; } - feed.add(_line('swap', actor: actor)); + if (restore != null) { + final d = next.duration; + position = restore.position < 0 ? 0 : (d > 0 && restore.position > d ? d : restore.position); + if (restore.transport == 'finished' && d > 0) { + position = d; + transport = Transport.finished; + } + } + feed.add(_line('swap', actor: actor, reason: restore == null ? '' : 'restore')); return true; } @@ -715,6 +825,7 @@ class SyncEngine { eventId++; transport = Transport.finished; feed.add(_line('finished')); + whatsNext(); return; } final blocked = diff --git a/packages/core/lib/src/watch_session.dart b/packages/core/lib/src/watch_session.dart index ecfebd7..ddc30bf 100644 --- a/packages/core/lib/src/watch_session.dart +++ b/packages/core/lib/src/watch_session.dart @@ -1,9 +1,9 @@ import 'dart:math' as math; // What one viewer actually watched of one opened item, and the rule that -// turns it into "watched", "barely looked" or "stopped part way". Pure, so -// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md -// "Watched rule". +// turns it into "watched", "barely looked" or "stopped part way" (D3). +// Pure, so every tracker (the device history, Jellyfin, AniList later) +// applies the same rule. See ARCHITECTURE.md "Watch tracking". /// What a [WatchSession] amounts to when it ends. enum WatchOutcome { @@ -14,7 +14,8 @@ enum WatchOutcome { /// whatever happened, with no resume point. keepPlayed, - /// Barely watched (a peek, a screenshot): leave the item as it was. + /// Barely watched (a peek, a screenshot visit, skipping around): leave + /// the item as it was. peek, /// Part way: resume from [WatchVerdict.resumeAt]. @@ -28,108 +29,105 @@ class WatchVerdict { final double? resumeAt; const WatchVerdict(this.outcome, {this.resumeAt}); + /// It counts as watched (played now, or played before). + bool get watched => outcome == WatchOutcome.played || outcome == WatchOutcome.keepPlayed; + @override String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})'; } -/// One opening of one item on this client. Fed the player position about -/// once a second ([sample]); only normal playback counts as watched: the -/// stretch between two samples while playing, when the position moved -/// forward no faster than [maxRate] (plus [slack]) and the samples are at -/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches, -/// so rewatching a part does not count twice; [watched] is the plain sum. +/// One opening of one item on this app. Fed about once a second +/// ([sample]) with the player's position, whether the media plays here +/// (the server's transport is playing and this player plays it) and +/// whether this viewer is In (present: `in`, from the presence the server +/// reports for it). The basis is [timeIn]: wall time the media played +/// while this viewer was In. Time sat out (Away), joining, gone, paused or +/// held never counts, nor does a jump: skipping around adds only the time +/// actually played. Two samples further apart than [maxGap] add nothing +/// (the app stalled). A resume carries the earlier watch: when what played +/// here starts at about the resume point ([resumeBefore], 60 s either +/// way) and nothing before it played, the time up to it counts as watched +/// before ([credited]). class WatchSession { - /// Played before this opening (Jellyfin `UserData.Played`). - final bool playedBefore; + /// Played before this opening (Jellyfin `UserData.Played`, the device's + /// watch history); set once known (Jellyfin answers after the open). + bool playedBefore; /// The resume point before this opening, seconds (0 = none). - final double resumeBefore; + double resumeBefore; - final double maxRate; - final double slack; final double maxGap; - WatchSession( - {this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10}); + WatchSession({this.playedBefore = false, this.resumeBefore = 0, this.maxGap = 10}); DateTime? startedAt; DateTime? endedAt; double? firstPosition; double? lastPosition; - /// The furthest position seen, reached by playing or by a jump. + /// The furthest position seen while In, reached by playing or by a jump. double furthest = 0; - /// Seconds of normal playback, rewatches included. - double watched = 0; + /// Seconds the media played while this viewer was In. + double timeIn = 0; - final _spans = <(double, double)>[]; - ({double pos, bool playing, DateTime at})? _prev; + /// The lowest position it played from while In; null before any. + double? playedFrom; - /// Seconds of the item covered by normal playback (rewatches once). - double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1); - - /// The watched stretches, sorted and merged. - List<(double, double)> get spans => List.unmodifiable(_spans); - - void sample(double position, {required bool playing, required DateTime at}) { - final prev = _prev; - _prev = (pos: position, playing: playing, at: at); - startedAt ??= at; - endedAt = at; - firstPosition ??= position; - lastPosition = position; - furthest = math.max(furthest, position); - if (prev == null || !prev.playing) return; - final dt = at.difference(prev.at).inMilliseconds / 1000; - final dp = position - prev.pos; - if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return; - watched += dp; - _add(prev.pos, position); + /// [timeIn] plus the time before the resume point when this opening + /// continued from it. + double get credited { + final from = playedFrom; + final resumed = resumeBefore > 0 && from != null && (from - resumeBefore).abs() <= 60; + return timeIn + (resumed ? resumeBefore : 0); } - void _add(double a, double b) { - final out = <(double, double)>[]; - var (lo, hi) = (a, b); - for (final (s, e) in _spans) { - if (e < lo || s > hi) { - out.add((s, e)); - } else { - lo = math.min(lo, s); - hi = math.max(hi, e); - } + ({DateTime at, double pos, bool counting})? _prev; + + void sample(double position, {required bool playing, required bool present, required DateTime at}) { + final prev = _prev; + _prev = (at: at, pos: position, counting: playing && present); + startedAt ??= at; + endedAt = at; + if (present) { + firstPosition ??= position; + lastPosition = position; + furthest = math.max(furthest, position); } - out - ..add((lo, hi)) - ..sort((x, y) => x.$1.compareTo(y.$1)); - _spans - ..clear() - ..addAll(out); + if (prev == null || !prev.counting) return; + final dt = at.difference(prev.at).inMilliseconds / 1000; + if (dt <= 0 || dt > maxGap) return; + timeIn += dt; + final from = playedFrom; + playedFrom = from == null ? prev.pos : math.min(from, prev.pos); } /// The rule, for an item [runtime] seconds long whose ending starts at /// [endingStart] (an Outro segment or ED chapter; null = the real end): /// - played before: [WatchOutcome.keepPlayed]; - /// - [covered] >= 70 % of the runtime, or the ending was reached - /// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The + /// - [credited] time In >= 70 % of the runtime, or the ending was + /// reached ([furthest]) with it >= 40 %: [WatchOutcome.played]. The /// ending is the earlier of [endingStart] and the last 15 % (at most - /// 4 min): someone who watched that far and stops is done with it; - /// - [covered] under max(2 min, 5 %) (at most a quarter of a short + /// 4 min): someone who watched that far and stops is done with it. + /// Any watch Jellyfin's own rule counts (a position past 90 %, reached + /// by playing) counts here too, also with the opening and ending + /// skipped; + /// - [credited] under max(2 min, 5 %) (at most a quarter of a short /// item): [WatchOutcome.peek]; /// - else [WatchOutcome.partial] at [lastPosition]. /// An unknown runtime (0) never makes it played. WatchVerdict verdict({required double runtime, double? endingStart}) { if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed); - final c = covered; + final t = credited; if (runtime > 0) { final tail = runtime - math.min(240.0, 0.15 * runtime); final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail; - if (c >= 0.7 * runtime || (furthest >= end && c >= 0.4 * runtime)) { + if (t >= 0.7 * runtime || (furthest >= end && t >= 0.4 * runtime)) { return const WatchVerdict(WatchOutcome.played); } } final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0; - if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek); + if (t < peekBelow) return const WatchVerdict(WatchOutcome.peek); return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0); } } diff --git a/packages/core/lib/src/whats_next.dart b/packages/core/lib/src/whats_next.dart new file mode 100644 index 0000000..1eb757b --- /dev/null +++ b/packages/core/lib/src/whats_next.dart @@ -0,0 +1,79 @@ +import 'presence.dart'; +import 'protocol.dart'; +import 'queue.dart'; + +// What's next, the rules every side reads the same way (PROTOCOL.md +// "Queue", ARCHITECTURE.md "What's next"): who puts an Up next item up. +// Pure and only on what `state` carries, so the server (which holds the +// countdown and skips what nobody can play) and every app agree without +// a wire message. + +/// Who puts an Up next item up: [id] a member id; null with [why] when +/// nobody here can (the server skips the item), null without [why] when +/// nobody is around right now (it waits). +typedef QueuePerformer = ({String? id, String? why}); + +/// Connected and watching (`in` or `joining`): can do things for the +/// party now (put an Up next item up). +bool isPresentMember(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining; + +/// - A link passed on by its adder, or the adder's file: only the adder +/// (their app has the file, the cookies). Gone (disconnected, in its +/// grace): it waits for them; left for good: skipped. +/// - Jellyfin: the adder when it plays that server with its own account, +/// else the lowest member id that does (`jf[serverId] == true`, +/// present). Nobody: skipped. +/// - A link everyone opens, or one the server fetches: the adder when +/// present, else the lowest present member id; nobody present: it waits. +QueuePerformer queuePerformer(QueueItem item, List members) { + MemberView? adder; + for (final m in members) { + if (m.id == item.by) adder = m; + } + if (item.how.adderOnly) { + if (adder == null) return (id: null, why: '${item.byName.isEmpty ? 'who added it' : item.byName} left'); + if (adder.presence == Presence.gone) return (id: null, why: null); + return (id: adder.id, why: null); + } + final ref = item.media.jellyfin; + if (item.how == QueueHow.jellyfin && ref != null) { + bool can(MemberView m) => isPresentMember(m) && m.jf[ref.serverId] == true; + if (adder != null && can(adder)) return (id: adder.id, why: null); + final id = lowestMemberId(members.where(can)); + return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null); + } + if (adder != null && isPresentMember(adder)) return (id: adder.id, why: null); + return (id: lowestMemberId(members.where(isPresentMember)), why: null); +} + +/// The lowest member id of [members] ("m2" before "m10"), or null. +String? lowestMemberId(Iterable members) { + final ids = [for (final m in members) m.id]..sort(compareMemberIds); + return ids.isEmpty ? null : ids.first; +} + +/// "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); +} + +/// Who may add the continuation ([QueueItem.auto]) of the current media: +/// who put it up ([swapBy], a member id), or, when that member is not +/// `in`, the first member (in [members] order, the join order) who is. +bool mayAddContinuation(String memberId, String? swapBy, List members) { + if (memberId.isEmpty) return false; + if (memberId == swapBy) return true; + final swapper = members.where((m) => m.id == swapBy).firstOrNull; + if (swapper != null && swapper.presence == Presence.watching) return false; + return members.where((m) => m.presence == Presence.watching).firstOrNull?.id == memberId; +} + +/// Whole seconds left of [advance] at [now] (server clock), at least 0. +int advanceSecondsLeft(QueueAdvance advance, DateTime now) { + final end = advance.at.add(Duration(seconds: advance.secs)); + final ms = end.difference(now).inMilliseconds; + return ms <= 0 ? 0 : (ms / 1000).ceil(); +} diff --git a/packages/core/test/core_test.dart b/packages/core/test/core_test.dart index dc66793..d10bf95 100644 --- a/packages/core/test/core_test.dart +++ b/packages/core/test/core_test.dart @@ -316,7 +316,7 @@ void main() { expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse); }); - test('state carries hold and the reserved advance', () { + test('state carries hold and advance', () { final at = DateTime.utc(2026); final s = StateBroadcast( eventId: 1, diff --git a/packages/core/test/queue_test.dart b/packages/core/test/queue_test.dart index 3d485a3..984fbf3 100644 --- a/packages/core/test/queue_test.dart +++ b/packages/core/test/queue_test.dart @@ -70,6 +70,7 @@ void main() { }); test('skip asks for the first item; a play request times out', () { + engine.applySwap('a', const HelloMedia(name: 'up', size: 0, duration: 0, url: 'https://x/up')); expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued'); engine.applyQueueAdd('a', link('one')); expect(engine.applyQueuePlay('a', ''), isTrue); @@ -171,4 +172,132 @@ void main() { expect(engine.media!.name, 'Ep02'); }); }); + + group("what's next (the server's countdown)", () { + QueueItem file(String title, {String key = 'abcdefgh12'}) => QueueItem( + media: HelloMedia(name: title, size: 3, duration: 0), how: QueueHow.file, title: title, key: key); + + // Both keep talking (pings), so nobody goes silent. + void after(int secs) { + t = t.add(Duration(seconds: secs)); + engine + ..touch('a') + ..touch('b') + ..tick(); + } + + /// Ep01 (20 s) up by alice, both ready and buffered, played to its end. + void finish() { + engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1')); + for (final tok in ['a', 'b']) { + engine.applyReady(tok, true); + engine.applyReport(tok, 0, 100, engine.eventId, mediaId: engine.mediaId); + } + expect(engine.transport, Transport.playing); + after(21); + expect(engine.transport, Transport.finished); + } + + + test('nothing up: the first item is asked for at once', () { + engine.applyQueueAdd('b', link('one')); + expect(engine.queue.play, idOf('one')); + expect(engine.advance, isNull); + }); + + test('at the end with an item first: 10 s for everyone, then queuePlay', () { + finish(); + expect(engine.advance, isNull, reason: 'nothing queued, nothing counts down'); + engine.applyQueueAdd('b', link('two')); + final a = engine.advance!; + expect([a.id, a.secs], [idOf('two'), RelayConstants.advanceSecs]); + expect(a.at, t); + expect(engine.broadcast().advance!.id, a.id, reason: 'in every state'); + expect(advanceSecondsLeft(a, t.add(const Duration(milliseconds: 2500))), 8); + after(5); + expect(engine.queue.play, isNull); + // A new first item takes the countdown over, its end unchanged. + engine.applyQueueAdd('a', link('first'), next: true); + expect([engine.advance!.id, engine.advance!.at], [idOf('first'), a.at]); + after(5); + expect(engine.queue.play, idOf('first')); + expect(engine.advance, isNull, reason: 'over once it asked'); + after(1); + expect(engine.advance, isNull, reason: 'not counted down again while the request waits'); + }); + + test('a seek stops it; cancel stops it for this media and drops the continuation', () { + engine.applyQueueAdd('b', link('two')); + finish(); + expect(engine.advance, isNotNull); + engine.applySeek('b', 5); + expect(engine.advance, isNull); + expect(engine.transport, isNot(Transport.finished)); + // Played to the end again: a fresh countdown. + after(16); + expect(engine.transport, Transport.finished); + expect(engine.advance, isNotNull); + engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId)); + engine.feed.drain(); + expect(engine.applyQueueCancel('b'), isTrue); + expect(engine.advance, isNull); + expect(titles(), ['two']); + expect(engine.feed.drain().single.action, 'advance-cancel'); + after(30); + expect((engine.advance, engine.queue.play), (null, null), reason: 'cancelled for this media'); + }); + + test("holds while its performer is gone, plays once they're back", () { + engine.applyQueueAdd('b', file('bobs')); + finish(); + engine.applyGone('b'); + after(12); + expect(engine.queue.play, isNull, reason: 'held: only bob has the file'); + expect(engine.advance, isNotNull); + engine.applyTakeover('b', seq: 1); + after(1); + expect(engine.queue.play, idOf('bobs')); + }); + + test("holds while the item's relay source is gone", () { + var sourceGone = true; + engine.itemSourceGone = (_) => sourceGone; + engine.applyQueueAdd('b', link('two')); + finish(); + after(12); + expect(engine.queue.play, isNull); + sourceGone = false; + after(1); + expect(engine.queue.play, idOf('two')); + }); + + test('what nobody here can put up is skipped by the server', () { + engine.applyQueueAdd('b', file('bobs')); + engine.applyQueueAdd('a', link('next')); + finish(); + engine.applyLeave('b', reason: 'bye'); + engine.feed.drain(); + after(11); + expect(titles(), ['next']); + final skip = engine.feed.drain().firstWhere((f) => f.action == 'queue-skip'); + expect([skip.actorId, skip.target, skip.reason], ['server', 'bobs', 'bob left']); + expect(engine.advance!.id, idOf('next'), reason: 'the countdown goes on with the next one'); + after(1); + expect(engine.queue.play, idOf('next')); + // A play request for a Jellyfin item nobody here can open: skipped too. + engine.applyQueueAdd('a', QueueItem( + media: const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')), + how: QueueHow.jellyfin, + title: 'ep')); + engine.applyQueuePlay('a', idOf('ep')); + expect(titles().contains('ep'), isFalse); + }); + + test('the continuation adds no feed line', () { + engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1')); + engine.feed.drain(); + engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId)); + expect(engine.feed.drain().where((f) => f.action == 'queue-add'), isEmpty); + }); + }); } diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index a0f57bc..76ebb37 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -388,6 +388,22 @@ void main() { group('swap', () { const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0); + test('a restore (after a restart) puts it back at its position, held; finished stays finished', () { + join('a', 'alice'); + engine.applySwap('a', ep2, restore: const SwapRestore(position: 42.5, transport: 'playing')); + expect((engine.positionAt(), engine.transport), (42.5, Transport.held)); + expect(engine.feed.drain().last.reason, 'restore'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.playing, reason: 'the gate starts it again'); + engine.applySwap('a', ep2, restore: const SwapRestore(position: 90, transport: 'finished')); + expect((engine.positionAt(), engine.transport), (90.0, Transport.finished)); + final rt = SwapIntent.fromJson( + const SwapIntent(media: ep2, restore: SwapRestore(position: 3, transport: 'playing')).toJson()); + expect((rt.restore!.position, rt.restore!.transport), (3.0, 'playing')); + expect(SwapIntent.fromJson(const SwapIntent(media: ep2).toJson()).restore, isNull); + }); + test('swap bumps mediaId, holds, keeps ready, fresh members in set', () { join('a', 'alice'); engine.applyReady('a', true); diff --git a/packages/core/test/watch_session_test.dart b/packages/core/test/watch_session_test.dart index bafac97..5905fa7 100644 --- a/packages/core/test/watch_session_test.dart +++ b/packages/core/test/watch_session_test.dart @@ -1,102 +1,151 @@ -// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present) import 'package:sync_relay_core/core.dart'; import 'package:test/test.dart'; -/// Plays [from]..[to] at normal speed, one sample a second, starting at -/// wall second [t]; returns the wall second after. -int play(WatchSession w, int t, double from, double to) { - var pos = from; - w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); - while (pos < to) { - t++; - pos = pos + 1 > to ? to : pos + 1; - w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); +final _t0 = DateTime(2026, 1, 1); + +/// One sample a second for [secs] seconds from wall second [t], the +/// position moving with the wall clock from [from] while [playing]; +/// returns the wall second after. +int run(WatchSession w, int t, double from, int secs, {bool playing = true, bool present = true}) { + for (var i = 0; i <= secs; i++) { + w.sample(from + (playing ? i : 0), playing: playing, present: present, at: _t0.add(Duration(seconds: t + i))); } - return t + 1; + return t + secs + 1; } void main() { const ep = 1440.0; // 24 min const ed = 1350.0; // the ending starts 1.5 min before the end - test('normal playback counts; seeks, skips and pauses do not', () { - final w = WatchSession(); - var t = play(w, 0, 0, 60); - // A jump ahead between two samples: not watched. - w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); - t = play(w, t + 1, 600, 630); - // Paused: the position does not move, nothing counts. - w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30))); - expect(w.covered, closeTo(90, 1)); - expect(w.spans, hasLength(2)); - expect(w.firstPosition, 0); - expect(w.lastPosition, 630); - expect(w.furthest, 630); + group('time In', () { + test('counts the time it played while In; pauses, holds and jumps add nothing', () { + final w = WatchSession(); + var t = run(w, 0, 0, 60); + // Held (the server waits for someone): the clock runs, nothing counts. + t = run(w, t, 60, 30, playing: false); + // A jump ahead: only the time actually played after it. + t = run(w, t, 600, 30); + expect(w.timeIn, closeTo(90, 2)); + expect(w.furthest, 630); + expect(w.firstPosition, 0); + }); + + test('sat out (Away) or joining: not counted, though the media plays on (P7)', () { + final w = WatchSession(); + var t = run(w, 0, 0, 120); // in for two minutes + t = run(w, t, 120, 1200, present: false); // sat out for the rest + expect(w.timeIn, closeTo(120, 2)); + expect(w.verdict(runtime: ep).outcome, WatchOutcome.partial); + expect(w.furthest, closeTo(120, 1), reason: 'positions while away are not this viewer\'s'); + }); + + test('a stall longer than the gap adds nothing', () { + final w = WatchSession(); + w.sample(0, playing: true, present: true, at: _t0); + w.sample(30, playing: true, present: true, at: _t0.add(const Duration(seconds: 30))); + expect(w.timeIn, 0); + }); + + test('drift corrections and small seeks while watching do not cost watched time (P9)', () { + final w = WatchSession(); + var pos = 0.0; + for (var i = 0; i <= 1200; i++) { + // Every 7 s the player is corrected half a second back or ahead. + if (i % 7 == 0) pos += i % 14 == 0 ? 0.5 : -0.5; + w.sample(pos, playing: true, present: true, at: _t0.add(Duration(seconds: i))); + pos += 1; + } + expect(w.timeIn, closeTo(1200, 1)); + expect(w.verdict(runtime: ep).outcome, WatchOutcome.played); + }); }); - test('rewatching a part counts once in coverage, twice in watched time', () { - final w = WatchSession(); - var t = play(w, 0, 0, 300); - t = play(w, t, 100, 200); // went back for a moment - expect(w.covered, closeTo(300, 1)); - expect(w.watched, closeTo(400, 1)); - }); + group('verdict (D3)', () { + test('played: 70 % of the runtime In, or the ending (or the last 15 %) reached after 40 %', () { + final full = WatchSession(); + run(full, 0, 0, 1200); // 83 % + expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); - test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () { - final full = WatchSession(); - play(full, 0, 0, 1200); // 83 % - expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); + // The opening and the ending skipped, stopped at the ED: Jellyfin's + // own rule (past 90 %) would count it too. + final skipped = WatchSession(); + var t = run(skipped, 0, 0, 90); + t = run(skipped, t, 180, 1170); // to the ED start + expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); + expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played); - final skipped = WatchSession(); - final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED - play(skipped, t, ed, ed + 5); - expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); - // Without a known ending the last 15 % (at most 4 min) counts. - expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played); + final half = WatchSession(); + t = run(half, 0, 0, 800); // 56 %, then a skip to the ED + run(half, t, ed, 5); + expect(half.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); + }); - // Watched with the opening skipped, stopped 3 min before the end. - final early = WatchSession(); - final u = play(early, 0, 0, 90); - play(early, u, 180, ep - 180); - expect(early.verdict(runtime: ep).outcome, WatchOutcome.played); + test('skipping around or a screenshot visit never counts', () { + // Through the whole episode in 30 s jumps, 2 s at each stop. + final hops = WatchSession(); + var t = 0; + for (var p = 0.0; p < ep; p += 30) { + t = run(hops, t, p, 2); + } + expect(hops.furthest, greaterThan(ed)); + expect(hops.verdict(runtime: ep, endingStart: ed).watched, isFalse); - final jumped = WatchSession(); - play(jumped, 0, 0, 400); // 28 %, then jumped to the end - jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2)); - expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial); - }); + final shot = WatchSession(); + run(shot, 0, 700, 20); + expect(shot.verdict(runtime: ep).outcome, WatchOutcome.peek); - test('played before: stays played however little was watched', () { - final w = WatchSession(playedBefore: true); - play(w, 0, 700, 730); // checking a moment - expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed); - }); + // A quarter watched, then a jump to the end: part way, not played. + final jumped = WatchSession(); + t = run(jumped, 0, 0, 400); + run(jumped, t, ep - 5, 5); + expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial); + }); - test('a peek leaves nothing; part way resumes at the last position', () { - final peek = WatchSession(resumeBefore: 300); - play(peek, 0, 900, 990); // 90 s - expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek); + test('played before: stays played however little was watched', () { + final w = WatchSession(playedBefore: true); + run(w, 0, 700, 30); + expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed); + expect(w.verdict(runtime: ep).watched, isTrue); + }); - final movie = WatchSession(); - play(movie, 0, 0, 300); // 5 min of 2 h: under 5 % - expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek); + test('a resume continues the earlier watch', () { + final resumed = WatchSession(resumeBefore: 600); + run(resumed, 0, 590, 800); // from the resume point to the end + expect(resumed.credited, closeTo(1400, 2)); + expect(resumed.verdict(runtime: ep).outcome, WatchOutcome.played); - final part = WatchSession(); - final t = play(part, 0, 0, 500); - part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); - final v = part.verdict(runtime: ep); - expect(v.outcome, WatchOutcome.partial); - expect(v.resumeAt, 450); + // Played from the start again: the earlier part is not counted twice. + final again = WatchSession(resumeBefore: 600); + run(again, 0, 0, 300); + expect(again.credited, closeTo(300, 2)); + }); - // A 3 min clip: half of it is not a peek. - final clip = WatchSession(); - play(clip, 0, 0, 90); - expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial); - }); + test('a peek leaves nothing; part way resumes at the last position', () { + final peek = WatchSession(resumeBefore: 300); + run(peek, 0, 900, 90); + expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek); - test('an unknown runtime never makes it played', () { - final w = WatchSession(); - play(w, 0, 0, 1000); - expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial); + final movie = WatchSession(); + run(movie, 0, 0, 300); // 5 min of 2 h: under 5 % + expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek); + + final part = WatchSession(); + final t = run(part, 0, 0, 500); + part.sample(450, playing: false, present: true, at: _t0.add(Duration(seconds: t))); + final v = part.verdict(runtime: ep); + expect(v.outcome, WatchOutcome.partial); + expect(v.resumeAt, 450); + + // A 3 min clip: half of it is not a peek. + final clip = WatchSession(); + run(clip, 0, 0, 90); + expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial); + }); + + test('an unknown runtime never makes it played', () { + final w = WatchSession(); + run(w, 0, 0, 1000); + expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial); + }); }); } diff --git a/packages/core/test/whats_next_test.dart b/packages/core/test/whats_next_test.dart new file mode 100644 index 0000000..e798eec --- /dev/null +++ b/packages/core/test/whats_next_test.dart @@ -0,0 +1,82 @@ +import 'package:sync_relay_core/core.dart'; +import 'package:test/test.dart'; + +MemberView member(String id, + {Presence presence = Presence.watching, Map jf = const {}, String name = ''}) => + MemberView( + id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, presence: presence, jf: jf); + +QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem( + id: 'q1', + media: how == QueueHow.jellyfin + ? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')) + : const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'), + how: how, + title: 'x', + by: by, + byName: byName, + ); + +void main() { + group('queuePerformer: who puts an item up (the server and every app read it the same)', () { + test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () { + for (final how in [QueueHow.through, QueueHow.file]) { + expect(queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.away)]).id, 'm2', + reason: 'sitting out, it comes back to put its own item up'); + final waits = queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.gone)]); + expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace'); + final left = queuePerformer(item(how), [member('m1'), member('m3')]); + expect((left.id, left.why), (null, 'Bob left')); + } + }); + + test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () { + final access = {'S': true}; + expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2'); + expect( + queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')]) + .id, + 'm3'); + expect( + queuePerformer( + item(QueueHow.jellyfin), [member('m3', jf: access, presence: Presence.away), member('m2')]) + .why, + 'nobody here has that Jellyfin server'); + }); + + test('a link everyone opens or the server fetches: the adder, else the lowest present member', () { + for (final how in [QueueHow.direct, QueueHow.server]) { + expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2'); + expect( + queuePerformer(item(how), [member('m4'), member('m2', presence: Presence.gone), member('m3')]).id, 'm3'); + final nobody = queuePerformer(item(how), [member('m2', presence: Presence.away)]); + expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip'); + } + }); + + test('member ids order by number: m2 before m10', () { + expect(compareMemberIds('m9', 'm10'), lessThan(0)); + expect(lowestMemberId([member('m10'), member('m9')]), 'm9'); + expect(lowestMemberId(const []), isNull); + }); + }); + + test('the continuation: who put the media up, else the first member in', () { + final members = [member('m1', presence: Presence.away), member('m2'), member('m3')]; + expect(mayAddContinuation('m1', 'm1', members), isTrue, reason: 'the swapper itself'); + expect(mayAddContinuation('m2', 'm1', members), isTrue, reason: 'the swapper is away: the first in'); + expect(mayAddContinuation('m3', 'm1', members), isFalse); + expect(mayAddContinuation('m2', 'm3', members), isFalse, reason: 'the swapper is in'); + expect(mayAddContinuation('m2', null, members), isTrue, reason: 'the media the server started with'); + expect(mayAddContinuation('', 'm1', members), isFalse); + }); + + test('seconds left of a countdown, on the server clock', () { + final at = DateTime.utc(2026, 1, 1); + final a = QueueAdvance(id: 'q1', at: at, secs: 10); + expect(advanceSecondsLeft(a, at), 10); + expect(advanceSecondsLeft(a, at.add(const Duration(milliseconds: 100))), 10); + expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 9, milliseconds: 1))), 1); + expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 12))), 0); + }); +} diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 6b07e80..04b18c4 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -50,7 +50,17 @@ class ServerHub { engine = engine ?? SyncEngine() { this.engine ..onGone = _memberGone - ..onLeft = _memberLeft; + ..onLeft = _memberLeft + ..itemSourceGone = _warmSourceGone; + } + + /// The warm relay of Up next item [queueId] waits for a member source + /// that is `gone`: the countdown holds (see `SyncEngine.whatsNext`). + bool _warmSourceGone(String queueId) { + final w = warm; + if (w == null || w.queueId != queueId || w.fetch || w.sourceToken == null) return false; + final src = engine.members[w.sourceToken]; + return src == null || src.presence == Presence.gone; } static String _randomId() { @@ -698,8 +708,16 @@ void mountSyncRoute( final swap = SwapIntent.fromJson(decoded); var parsed = swap.media; final queueId = swap.queueId; + final restore = swap.restore; + // What played before a restart, offered back: the first + // offer wins (see `swap.restore`). + if (restore != null && hub.engine.media != null) { + _hubLog.info('restore by ${hub.who(token)} ignored: media is up already'); + return; + } _hubLog.info('swap by ${hub.who(token)}: "${parsed.name}" ${_swapKind(parsed)}' - '${queueId == null ? '' : ' (Up next $queueId)'}'); + '${queueId == null ? '' : ' (Up next $queueId)'}' + '${restore == null ? '' : ' (restored at ${restore.position.toStringAsFixed(1)} s, ${restore.transport})'}'); if (queueId != null) { // Already put up by someone else: nothing to do. if (!hub.engine.queue.has(queueId)) return; @@ -737,7 +755,7 @@ void mountSyncRoute( : promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, const MediaRelay())); - if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) { + if (media == null || !hub.engine.applySwap(token, media, queueId: queueId, restore: restore)) { promoted?.dispose(); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); return; @@ -785,7 +803,7 @@ void mountSyncRoute( duration: parsed.duration, ref: parsed.ref, relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq)); - if (!hub.engine.applySwap(token, media, queueId: queueId)) { + if (!hub.engine.applySwap(token, media, queueId: queueId, restore: restore)) { promoted?.dispose(); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); return; @@ -827,7 +845,7 @@ void mountSyncRoute( wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap'); return; } - if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) { + if (!hub.engine.applySwap(token, intent.media, queueId: queueId, restore: restore)) { wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); return; } diff --git a/packages/server/test/queue_test.dart b/packages/server/test/queue_test.dart index 984c1e9..4dc669d 100644 --- a/packages/server/test/queue_test.dart +++ b/packages/server/test/queue_test.dart @@ -141,4 +141,110 @@ void main() { expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again'); expect(back.members.where((m) => m.name == 'alice'), hasLength(1)); }); + + test('after a restart, the first offer of what played wins; later ones are ignored', () async { + final a = await _Peer.join(server.boundPort, 'alice'); + final b = await _Peer.join(server.boundPort, 'bob'); + const media = HelloMedia(name: 'v', size: 0, duration: 600, url: 'https://example.com/v.mp4'); + a.send(const SwapIntent(media: media, restore: SwapRestore(position: 120.5, transport: 'playing')).toJson()); + final back = await b.state((s) => s.media != null); + expect((back.position, back.transport), (120.5, 'held')); + final line = await b.until((m) => m['t'] == 'feed' && m['action'] == 'swap'); + expect(line['reason'], 'restore'); + b.send(const SwapIntent( + media: HelloMedia(name: 'w', size: 0, duration: 0, url: 'https://example.com/w.mp4'), + restore: SwapRestore(position: 3)) + .toJson()); + a.msgs.clear(); + final later = await a.state((s) => true); + expect((later.mediaId, later.media?['name'], later.position), (back.mediaId, 'v', 120.5)); + }); + + group('the countdown (state.advance), on a fake clock', () { + late DateTime t; + late SyncServer clocked; + + setUp(() async { + t = DateTime.utc(2026, 1, 1); + clocked = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t); + await clocked.start(); + }); + + tearDown(() => clocked.stop()); + + /// Moves the clock on; everyone keeps talking; one heartbeat. + void step(int secs) { + t = t.add(Duration(seconds: secs)); + final engine = clocked.hub.engine; + for (final token in engine.members.keys) { + engine.touch(token); + } + clocked.hub.heartbeat(); + } + + /// Alice and bob in; a 20 s video up, both ready with a full buffer, + /// then played to its end; [queue] what bob adds first. + Future<(_Peer, _Peer)> playToEnd(List queue) async { + final a = await _Peer.join(clocked.boundPort, 'alice'); + final b = await _Peer.join(clocked.boundPort, 'bob'); + a.send(const SwapIntent(media: HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1.mp4')) + .toJson()); + final up = await b.state((s) => s.media != null); + for (final p in [a, b]) { + p.send(const ReadyIntent(ready: true).toJson()); + p.send(ReportMessage(position: 0, buffered: 100, eventId: up.eventId + 1, mediaId: up.mediaId).toJson()); + } + await b.state((s) => s.transport == 'playing'); + for (final q in queue) { + b.send(QueueIntent.add(q).toJson()); + } + await b.state((s) => s.queue.length == queue.length); + step(21); + return (a, b); + } + + const next = QueueItem( + media: HelloMedia(name: 'n', size: 0, duration: 0, url: 'https://example.com/n.mp4'), + how: QueueHow.direct, + title: 'Next'); + + test('everyone sees the same countdown; at its end the first item is asked for', () async { + final (a, b) = await playToEnd([next]); + final s = await a.state((s) => s.advance != null); + expect(s.transport, 'finished'); + expect([s.advance!.id, s.advance!.secs], [s.queue.first.id, RelayConstants.advanceSecs]); + expect((await b.state((s) => s.advance != null)).advance!.at, s.advance!.at); + step(5); + expect((await a.state((s) => true)).queuePlay, isNull); + step(5); + final due = await a.state((s) => s.queuePlay != null); + expect(due.queuePlay, s.queue.first.id); + expect(due.advance, isNull); + }); + + test('cancel is shared: the countdown stops for everyone', () async { + final (a, b) = await playToEnd([next]); + await a.state((s) => s.advance != null); + b.send(const QueueIntent.cancel().toJson()); + await a.state((s) => s.advance == null); + final line = await a.until((m) => m['t'] == 'feed' && m['action'] == 'advance-cancel'); + expect(line['actor'], 'bob'); + step(15); + expect(clocked.hub.engine.queue.play, isNull); + }); + + test('an item whose adder left is skipped by the server, with why', () async { + final (a, b) = await playToEnd([ + const QueueItem( + media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'Bobs', key: 'k1234567890'), + ]); + await a.state((s) => s.advance != null); + b.send(const ByeMessage().toJson()); + await a.until((m) => m['t'] == 'feed' && m['action'] == 'leave'); + step(10); + final skip = await a.until((m) => m['t'] == 'feed' && m['action'] == 'queue-skip'); + expect([skip['actorId'], skip['target'], skip['reason']], ['server', 'Bobs', 'bob left']); + expect((await a.state((s) => true)).queue, isEmpty); + }); + }); }