Restructure step 6: what's next and watch tracking
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s

- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 02:06:04 +03:00
1 parent a87ee98faa
commit 741b258ac1
55 files changed
+2724 -964

No files matched your search

+30 -21
View File
@@ -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 "Show (Season 3 - Alicization)" gives the season and its name. Shows
are looked up on AniList for their proper name, cover and banner. A 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 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 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?"). Matched 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 the wrong show? **Wrong show?** searches AniList and pins the right one
for the show everywhere or just one folder; **Episode numbers…** says for the show everywhere or just one folder; **Episode numbers…** says
which episode the first file is when the files count differently. Both 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. you added stays when you leave.
In the player, Up next sits in the top bar, right of the title: the 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 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 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 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, 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 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 **+ 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) what is up next with a 10 s countdown everyone sees, and it plays;
and it plays; `>` or Shift+N plays it at once. Under the queue, Cancel stops it for everyone, and so does seeking back. `>` or Shift+N
**Continues with** shows the next episodes of the Jellyfin series plays it at once. Under the queue, **Continues with** shows what
playing (greyed: they play only once Up next is empty); **+** adds one, continues the episode playing: its next episode (a Jellyfin episode's,
**Clear** drops them for this episode. 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 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 …: 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 Foxyz left"). If you are only gone for a moment, the countdown waits for
server. Up next is kept by the server while it runs; restarting the you. A Jellyfin item needs someone with an account on that server. Up
server empties it. 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 ## Schedule
@@ -473,10 +480,13 @@ Watching:
directly with their own sign-in. Jellyfin tracks progress per account: directly with their own sign-in. Jellyfin tracks progress per account:
your watched state, Continue watching and resume point follow what you your watched state, Continue watching and resume point follow what you
saw (reported on start, every 10 s, on pause, resume, seek and track 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 change, and when it stops). Watched means you were there while most
it (or reached the ending after half), not where you stopped: opening of it played (or while half of it played and it reached the ending),
a watched episode to check a moment keeps it watched, and a quick peek not where it stopped: time you sat out does not count, skipping around
leaves no resume point. 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 - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never 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 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 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 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 minutes) a "Next episode" button floats in the corner while the controls
are up. When an are up. With "Play the next episode automatically" (Settings, on by
episode ends, a card shows what is next with Play next; with "Play the default), the episode you put up continues with the next one: when it
next episode automatically" (Settings, on by default) it counts down 10 s ends, everyone sees a 10 s countdown, and anyone can cancel it (see Up
first, and Cancel stops it on your screen (seeking back stops it for next). Anyone with an account can put the next one up; friends watching
everyone). Anyone with an account can put the next one up; friends via the host cannot.
watching via the host cannot.
Audio and subtitles: the Audio and Subtitles buttons in the controls pick tracks. 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 Your choice is remembered per series (and as a default) and the closest
+3 -1
View File
@@ -66,8 +66,10 @@ Future<void> main(List<String> args) async {
} catch (e, st) { } catch (e, st) {
debugPrint('load connect form failed: $e\n$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.library.ensureLoaded());
unawaited(graph.watch.history.ensureLoaded());
ports.players.init(); ports.players.init();
thumbnailPlayers = ports.players; thumbnailPlayers = ports.players;
// yt-dlp for web page links: downloaded on first run, kept current. // yt-dlp for web page links: downloaded on first run, kept current.
+26 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../library/library_store.dart'; import '../library/library_store.dart';
import '../library/local_next.dart';
import '../library/show_matcher.dart'; import '../library/show_matcher.dart';
import '../platform/app_log.dart' show appLogDir; import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart'; import '../platform/desktop/desktop_ports.dart';
@@ -20,6 +21,8 @@ import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import '../state/settings.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 /// The composition root: builds the app's systems once (in `main`), wires
/// them through their constructors and disposes them. No logic and no /// 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. /// The library's matches (L2): local shows to AniList, pins, offsets.
final ShowMatcher library; final ShowMatcher library;
/// Watch tracking and the device's watch history (D3).
final WatchTracker watch;
AppGraph._({ AppGraph._({
required this.ports, required this.ports,
required this.sfx, required this.sfx,
@@ -61,6 +67,7 @@ class AppGraph {
required this.yourServer, required this.yourServer,
required this.navigation, required this.navigation,
required this.library, required this.library,
required this.watch,
}); });
/// The desktop app's graph. Tests pass their own [ports], [identity] /// The desktop app's graph. Tests pass their own [ports], [identity]
@@ -72,12 +79,14 @@ class AppGraph {
ServerStarter? serverStarter, ServerStarter? serverStarter,
JellyfinAccounts? jellyfin, JellyfinAccounts? jellyfin,
ShowMatcher? library, ShowMatcher? library,
WatchHistory? history,
}) { }) {
final p = ports ?? desktopPorts(); final p = ports ?? desktopPorts();
final settings = AppSettings.instance;
final sfx = SynthSfx( 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). // 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, presetFor: soundPresetFor,
); );
final notifier = Notifier( final notifier = Notifier(
@@ -85,11 +94,19 @@ class AppGraph {
attention: p.shell.requestAttention, attention: p.shell.requestAttention,
system: p.notifications, system: p.notifications,
focusWindow: p.shell.focus, focusWindow: p.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs, prefs: () => settings.notifyPrefs,
); );
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored()); 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( 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. // Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ?? final matcher = library ??
ShowMatcher( ShowMatcher(
@@ -97,7 +114,9 @@ class AppGraph {
aniList: AniListClient(), aniList: AniListClient(),
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)), 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 forms = ConnectStore();
final yourServer = YourServer( final yourServer = YourServer(
forms: forms, forms: forms,
@@ -137,12 +156,14 @@ class AppGraph {
yourServer: yourServer, yourServer: yourServer,
navigation: navigation, navigation: navigation,
library: matcher, library: matcher,
watch: watch,
); );
} }
Future<void> dispose() async { Future<void> dispose() async {
navigation.dispose(); navigation.dispose();
await coordinator.dispose(); await coordinator.dispose();
await watch.dispose();
await yourServer.stop(); await yourServer.stop();
await sfx.dispose(); await sfx.dispose();
} }
@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
const Expanded( const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()), 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), const SizedBox(width: 4),
SignalBuilder( SignalBuilder(
builder: (context) { builder: (context) {
@@ -81,13 +81,16 @@ class PlayerControlsModel {
/// Everything the layout depends on (not the position: that changes /// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it). /// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() { late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value; final queued = queue.items.value.firstOrNull;
final continues = queue.continues.value;
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
return ControlsSnapshot( return ControlsSnapshot(
play: play.value, play: play.value,
previousLabel: coord.previousEpisode.value?.label, previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label, // Up next's first item, else the next episode, else what continues
nextIsUpNext: items.isNotEmpty, // this (a file's next episode).
nextLabel: queued?.title ?? next?.label ?? continues?.title,
nextIsUpNext: queued != null,
hasChapters: chapters.value.isNotEmpty, hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero, hasDuration: duration.value > Duration.zero,
alone: coord.alone.value, alone: coord.alone.value,
@@ -137,9 +140,10 @@ class PlayerControlsModel {
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6); 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() { bool next() {
if (queue.items.peek().isNotEmpty) { if (queue.head.peek() != null) {
queue.skip(); queue.skip();
return true; return true;
} }
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
return Row( return Row(
children: [ children: [
const Expanded(child: NowPlayingTitle()), 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( SignalBuilder(
builder: (context) { builder: (context) {
final full = m.fullscreen.value; final full = m.fullscreen.value;
+11 -1
View File
@@ -9,6 +9,7 @@ import '../../library/show_matcher.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart'; import '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart'; import '../../app/app_graph.dart';
import '../../watch/watch_history.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../scanner.dart'; import '../scanner.dart';
@@ -127,6 +128,7 @@ class _FilesPageState extends State<FilesPage> {
late final ThumbCache _thumbs = late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance; FolderStore get _store => widget.store ?? FolderStore.instance;
WatchHistory get _history => AppScope.of(context).watch.history;
/// Null: Recent; [libraryPlace]: every folder. /// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen; String? _open = _lastOpen;
@@ -325,8 +327,15 @@ class _FilesPageState extends State<FilesPage> {
thumbs: _thumbs, 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) { 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) { for (final p in _store.recent.value) {
final e = lib.byPath[p]; final e = lib.byPath[p];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key; if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
@@ -342,6 +351,7 @@ class _FilesPageState extends State<FilesPage> {
final folders = _store.folders.value; final folders = _store.folders.value;
final recent = _store.recent.value; final recent = _store.recent.value;
_matcher.revision.value; _matcher.revision.value;
_history.revision.value;
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace; final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
final showKey = open == null ? null : _openShows[open]; final showKey = open == null ? null : _openShows[open];
final main = fadeThrough( final main = fadeThrough(
+15 -14
View File
@@ -26,13 +26,14 @@ class PlayerSnapshot {
/// (the session shows one quiet toast) and never touches playback. A 401 /// (the session shows one quiet toast) and never touches playback. A 401
/// calls [onUnauthorized] and ends reporting. /// calls [onUnauthorized] and ends reporting.
/// ///
/// Watched or not is [WatchSession]'s rule, not Jellyfin's position /// Watched or not is [WatchSession]'s rule (time In, D3), not Jellyfin's
/// percentages (see ARCHITECTURE.md "Jellyfin in a session"): an item /// position percentages (see ARCHITECTURE.md "Watch tracking"): the
/// played before ([playedBefore]) stays played (re-marked after Playing, /// session's watch tracker feeds [watch] and this reporter only reads its
/// Progress without a position, Stopped without one, then marked played /// verdict. An item played before ([playedBefore]) stays played
/// again); otherwise Progress leaves the position out past /// (re-marked after Playing, Progress without a position, Stopped without
/// [_noPositionFrom] of the runtime so Jellyfin cannot mark it played on /// one, then marked played again); otherwise Progress leaves the position
/// its own, and Stopped carries what the rule says. /// out past [_noPositionFrom] of the runtime so Jellyfin cannot mark it
/// played on its own, and Stopped carries what the rule says.
class JellyfinProgressReporter { class JellyfinProgressReporter {
final JellyfinClient client; final JellyfinClient client;
final String itemId; final String itemId;
@@ -43,7 +44,8 @@ class JellyfinProgressReporter {
final void Function(Object error)? onError; final void Function(Object error)? onError;
final VoidCallback? onUnauthorized; 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; final WatchSession watch;
JellyfinProgressReporter({ JellyfinProgressReporter({
@@ -57,12 +59,14 @@ class JellyfinProgressReporter {
this.onError, this.onError,
this.onUnauthorized, this.onUnauthorized,
this.onStopped, this.onStopped,
WatchSession? watch,
DateTime Function()? clock, DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now, }) : _clock = clock ?? DateTime.now,
_keepPlayed = playedBefore, _keepPlayed = playedBefore,
_runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond, _runtime = (source.runTimeTicks ?? 0) / jellyfinTicksPerSecond,
watch = WatchSession( watch = (watch ?? WatchSession())
playedBefore: playedBefore, resumeBefore: resumeTicksBefore / jellyfinTicksPerSecond); ..playedBefore = playedBefore
..resumeBefore = resumeTicksBefore / jellyfinTicksPerSecond;
/// Past this share of the runtime Progress carries no position: Jellyfin /// Past this share of the runtime Progress carries no position: Jellyfin
/// marks an item played past its "max resume" share (90 % by default). /// marks an item played past its "max resume" share (90 % by default).
@@ -126,7 +130,6 @@ class JellyfinProgressReporter {
if (_playing || _dead) return; if (_playing || _dead) return;
_playing = true; _playing = true;
_mark(s); _mark(s);
watch.sample(s.position, playing: !s.paused, at: _clock());
_send('Playing', () => client.reportPlaybackStart(reportFor(s))); _send('Playing', () => client.reportPlaybackStart(reportFor(s)));
if (_keepPlayed) _send('played', () => client.markPlayed(itemId)); if (_keepPlayed) _send('played', () => client.markPlayed(itemId));
} }
@@ -136,7 +139,6 @@ class JellyfinProgressReporter {
if (!_playing || _dead) return; if (!_playing || _dead) return;
final last = _last, lastAt = _lastAt, sentAt = _lastSentAt; final last = _last, lastAt = _lastAt, sentAt = _lastSentAt;
final now = _clock(); final now = _clock();
watch.sample(s.position, playing: !s.paused, at: now);
var due = force || last == null || lastAt == null || sentAt == null; var due = force || last == null || lastAt == null || sentAt == null;
if (!due) { if (!due) {
final elapsed = now.difference(lastAt).inMilliseconds / 1000; final elapsed = now.difference(lastAt).inMilliseconds / 1000;
@@ -163,11 +165,10 @@ class JellyfinProgressReporter {
Future<void> stop(PlayerSnapshot s) { Future<void> stop(PlayerSnapshot s) {
if (!_playing || _dead) return _chain; if (!_playing || _dead) return _chain;
_playing = false; _playing = false;
watch.sample(s.position, playing: !s.paused, at: _clock());
final v = _keepPlayed final v = _keepPlayed
? const WatchVerdict(WatchOutcome.keepPlayed) ? const WatchVerdict(WatchOutcome.keepPlayed)
: watch.verdict(runtime: _runtime, endingStart: _endingStart); : 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; final int? ticks;
switch (v.outcome) { switch (v.outcome) {
case WatchOutcome.played || WatchOutcome.keepPlayed: case WatchOutcome.played || WatchOutcome.keepPlayed:
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../library/media_refs.dart'; import '../library/media_refs.dart';
@@ -341,26 +340,3 @@ Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? ending
const lead = Duration(minutes: 2); const lead = Duration(minutes: 2);
return runtime > lead ? runtime - lead : Duration.zero; 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<MemberView> 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);
}
@@ -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<FolderScan> Function(String dir) scan;
LocalNextFinder({required this.matcher, required this.scan});
Future<LocalNext?> 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);
}
}
+11 -1
View File
@@ -129,6 +129,7 @@ class ShowMatcher {
// Files seen in scans this run, for refs of files put up. // Files seen in scans this run, for refs of files put up.
final _byPath = <String, LocalEpisode>{}; final _byPath = <String, LocalEpisode>{};
final _showByPath = <String, LocalShow>{};
final _entries = <int, AniListEntry>{}; final _entries = <int, AniListEntry>{};
final _flows = <int, FlowShow?>{}; final _flows = <int, FlowShow?>{};
final _jobs = <String, Future<void> Function()>{}; final _jobs = <String, Future<void> Function()>{};
@@ -152,13 +153,22 @@ class ShowMatcher {
// ------------------------------------------------------------- reads // ------------------------------------------------------------- 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) { void remember(LocalLibrary library) {
for (final e in library.files) { for (final e in library.files) {
_byPath[e.path] = e; _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) { AniListEntry? entry(int id) {
final known = _entries[id]; final known = _entries[id];
if (known != null) return known; if (known != null) return known;
+199 -123
View File
@@ -16,23 +16,27 @@ import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext; import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart'; import '../session/media_art.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart'; import '../sync/avatar_cache.dart';
import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's // What's next on this app (see ARCHITECTURE.md "What's next" and "Up
// queue as signals, adding to it (probed, with a picture), the end-of-media // next"): the server's queue as signals (Up next and its continuation),
// countdown for its first item, putting an item up when this app is the // adding to it (probed, with a picture), the continuation of what this
// one that can, and keeping that item ready. // 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: /// The end-of-media card for the first item (Up next's, else the
/// no countdown here (auto-advance off, cancelled, or another app puts /// continuation). [secondsLeft]: the server's countdown (`state.advance`);
/// it up), the card offers Play now only. /// 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 { class UpNextCountdown {
final int mediaId; final int mediaId;
final QueueItem item; final QueueItem item;
final int? secondsLeft; 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. /// The first item's readiness on this app.
@@ -46,21 +50,33 @@ class QueueController {
late final PictureCache thumbs = PictureCache(coord.connection.client, late final PictureCache thumbs = PictureCache(coord.connection.client,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]); 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). /// Up next, in order, without the continuation (empty when nothing is
late final Computed<List<QueueItem>> items = /// queued or not in a server).
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]); late final Computed<List<QueueItem>> items = computed(
() => [for (final q in coord.serverState.value?.queue ?? const <QueueItem>[]) if (!q.auto) q]);
/// The continuation of what plays (`auto`: the next episode), shared by
/// everyone; null when none.
late final Computed<QueueItem?> 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<QueueItem?> head = computed(() => coord.serverState.value?.queue.firstOrNull);
/// The end card while the media has ended with something next (see
/// [UpNextCountdown]).
final countdown = signal<UpNextCountdown?>(null); final countdown = signal<UpNextCountdown?>(null);
/// The first item's id and how ready it is here (null: nothing to get /// The first item's id and how ready it is here (null: nothing to get
/// ready on this app). /// ready on this app).
final warm = signal<({String id, WarmState state})?>(null); final warm = signal<({String id, WarmState state})?>(null);
/// The natural continuation of what plays: the next episodes of the /// The next episodes of the Jellyfin series playing, on an app that
/// Jellyfin series (not queued; shown greyed under Up next). /// 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<List<EpisodeLink>>(const []); final continuation = signal<List<EpisodeLink>>(const []);
/// The mediaId whose continuation was cleared here. /// The mediaId whose continuation was cleared (by anyone: `cancel`).
final _hiddenFor = signal(-1); final _hiddenFor = signal(-1);
/// The queue popover is open: the Up next button holds the controls up. /// 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. // here, a file here). Never leaves this app.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {}; final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {}; final Map<String, DateTime> _performing = {};
final Set<String> _skipped = {};
int _cancelledFor = -1;
Timer? _timer; Timer? _timer;
bool _attached = false; 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 = ''; String _continuationFor = '';
final _random = Random.secure(); final _random = Random.secure();
@@ -83,15 +101,26 @@ class QueueController {
if (_attached) return; if (_attached) return;
_attached = true; _attached = true;
coord.connection.states.listen(_onState); 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(() { effect(() {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value; final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan)); unawaited(_loadContinuation(next, plan));
}); });
// The continuation and the state decide what to get ready; the media // What continues this media becomes known (the next episode found, a
// playing having started (or sitting out) lets the preload go. // 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(() { effect(() {
shownContinuation.value;
coord.settledMediaId.value; coord.settledMediaId.value;
coord.selfAway.value; coord.selfAway.value;
final r = coord.serverState.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() { void skip() {
final first = items.peek().firstOrNull; final first = head.peek();
if (first != null) playNow(first.id); 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() { void cancelCountdown() {
final c = countdown.peek(); coord.markInput();
_timer?.cancel(); coord.connection.sendQueue(const QueueIntent.cancel());
_timer = null;
if (c == null) return;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
} }
/// "Clear": hides the continuation of what plays now (and its next /// "Clear" under "Continues with": the continuation of what plays goes
/// episode does not come up by itself). /// for everyone (`cancel`) and does not come back for this media.
void clearContinuation() { void clearContinuation() {
final id = coord.serverState.peek()?.mediaId; final id = coord.serverState.peek()?.mediaId;
if (id != null) _hiddenFor.value = id; 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 /// The further episodes of [continuation] (after the one that continues
/// not cleared. /// what plays), unless it was cleared for the media playing.
bool continuationShown(int mediaId) =>
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
/// [continuation] unless cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() { late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
final id = coord.serverState.value?.mediaId; final id = coord.serverState.value?.mediaId;
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value; if (id == null || _hiddenFor.value == id) return const <EpisodeLink>[];
final auto = continues.value?.media.jellyfin?.itemId;
return [for (final e in continuation.value) if (e.item.id != auto) e];
}); });
// --------------------------------------------------------- per state // --------------------------------------------------------- per state
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
_lastState = 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); _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; final play = r.queuePlay;
if (play != null) { if (play != null) {
final item = queue.where((q) => q.id == play).firstOrNull; final item = r.queue.where((q) => q.id == play).firstOrNull;
if (item != null) _bringUp(item, r); if (item != null && queuePerformer(item, r.members).id == _me) _perform(item);
}
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;
} }
_syncCountdown(r);
_continue(r);
_warm(r); _warm(r);
} }
@@ -385,75 +406,133 @@ class QueueController {
final now = DateTime.now(); final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1)); _performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
final keys = {for (final q in r.queue) q.key}; 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)); // Kept while a restarted server waits for what played: Up next comes
final ids = {for (final q in r.queue) q.id}; // back with it.
_skipped.removeWhere((id) => !ids.contains(id)); 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; /// The end card from the server's state: the media ended with something
/// nobody can: the lowest member takes it off with the reason. /// next; the seconds of `state.advance` move on with a 1 s timer.
void _bringUp(QueueItem item, StateBroadcast r) { void _syncCountdown(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.
final head = r.queue.firstOrNull; 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() { // ------------------------------------------------------------ restart
_timer?.cancel();
_timer = null; /// The server restarted without media; what played ([mediaId]) is being
countdown.value = null; /// 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<void> _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). /// Puts [item] up from here (once per item while it is in flight).
@@ -530,9 +609,8 @@ class QueueController {
bool _ownAccount(String serverId) => bool _ownAccount(String serverId) =>
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired; 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) /// What this app gets ready: the first item of the queue (Up next's,
/// the first continuation episode (only here when this app plays the /// else the continuation). Every app does, each its own copy (see
/// series with its own account). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app /// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing. /// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) { _WarmTarget? _warmTarget(StateBroadcast? r) {
@@ -579,9 +657,7 @@ class QueueController {
return performer ? _WarmTarget(head.id) : null; return performer ? _WarmTarget(head.id) : null;
} }
} }
final next = shownContinuation.peek().firstOrNull; return null;
if (next == null) return null;
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
} }
/// Keeps the one item that plays next ready on this app, so a skip or /// Keeps the one item that plays next ready on this app, so a skip or
+4 -55
View File
@@ -1,66 +1,15 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/session_media.dart' show compareMemberIds; // Up next's words for an item. Who puts an item up is core's
import '../session/party.dart' show isPresent; // `queuePerformer` (whats_next.dart), read the same by the server and
// every app.
// 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<MemberView> 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<MemberView> 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<MemberView> members) =>
autoAdvanceLowest(members.where((m) => m.presence != Presence.gone));
/// How [item] plays, in a few words for its row: "through Foxyz" (you: /// How [item] plays, in a few words for its row: "through Foxyz" (you:
/// "through you"), "server fetches", "everyone fetches", "Jellyfin", /// "through you"), "server fetches", "everyone fetches", "Jellyfin",
/// "Foxyz's file". /// "Foxyz's file".
String queueHowLabel(QueueItem item, {required String selfId}) { String queueHowLabel(QueueItem item, {required String selfId}) {
final mine = item.by == selfId && selfId.isNotEmpty; 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) { return switch (item.how) {
QueueHow.through => 'through $who', QueueHow.through => 'through $who',
QueueHow.file => mine ? 'your file' : "$who's file", QueueHow.file => mine ? 'your file' : "$who's file",
+127 -81
View File
@@ -12,7 +12,6 @@ import '../controls/minimal/style.dart';
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/session_coordinator.dart' show autoAdvanceSecs;
import '../shell/avatar.dart'; import '../shell/avatar.dart';
import '../shell/handoff.dart' show handoffHolds; import '../shell/handoff.dart' show handoffHolds;
import '../shell/library_link.dart' show LinkPreview, cachedLinkPreview; 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 ⏭ /// 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 /// (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. /// 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. /// plays it). Nothing when both are empty.
class UpNextButton extends StatefulWidget { class UpNextButton extends StatefulWidget {
final QueueController queue; final QueueController queue;
/// Plays the continuation's first episode (the next episode).
final VoidCallback onPlayNextEpisode;
final UpNextLook look; final UpNextLook look;
/// Held while the preview card is up, so the controls stay up with it. /// Held while the preview card is up, so the controls stay up with it.
final ControlsHold? hold; final ControlsHold? hold;
const UpNextButton( const UpNextButton({super.key, required this.queue, required this.look, this.hold});
{super.key, required this.queue, required this.onPlayNextEpisode, required this.look, this.hold});
@override @override
State<UpNextButton> createState() => _UpNextButtonState(); State<UpNextButton> createState() => _UpNextButtonState();
@@ -143,9 +139,9 @@ class _UpNextButtonState extends State<UpNextButton> {
final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0; final thumbRadius = _minimal ? MinimalStyle.radius - 4 : 6.0;
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final items = _q.items.value; final items = _q.items.value;
final cont = _q.shownContinuation.value; final auto = _q.continues.value;
final open = _q.popoverOpen.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. // Gone with the preview up: let go of the controls.
if (_preview.isShowing) { if (_preview.isShowing) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -155,21 +151,15 @@ class _UpNextButtonState extends State<UpNextButton> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final queued = items.isNotEmpty; final queued = items.isNotEmpty;
final item = items.firstOrNull; final item = items.firstOrNull ?? auto;
final episode = cont.firstOrNull;
final more = items.length - 1; final more = items.length - 1;
final String kicker; final String kicker;
final String title; final String title;
final Widget thumb; final Widget thumb;
if (item != null) { if (item != null) {
kicker = 'UP NEXT'; kicker = queued ? 'UP NEXT' : (item.sub.isEmpty ? 'NEXT' : 'NEXT · ${item.sub}');
title = item.title; title = item.title;
thumb = QueueThumb(queue: _q, item: item, width: thumbWidth, radius: thumbRadius); 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 { } else {
kicker = 'UP NEXT'; kicker = 'UP NEXT';
title = 'Nothing yet'; title = 'Nothing yet';
@@ -246,10 +236,8 @@ class _UpNextButtonState extends State<UpNextButton> {
), ),
), ),
); );
final playTip = queued final playTip = 'Play ${item?.title ?? ''} now ($playNextKeys)';
? 'Play ${item!.title} now ($playNextKeys)' final VoidCallback? play = item == null ? null : _q.skip;
: 'Play ${episode == null ? '' : episode.label} now ($playNextKeys)';
final VoidCallback? play = queued ? _q.skip : (episode != null ? widget.onPlayNextEpisode : null);
final playButton = _minimal final playButton = _minimal
? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play) ? MinimalButton(key: const ValueKey('up-next-play'), icon: Icons.skip_next_rounded, tooltip: playTip, onPressed: play)
: Tooltip( : Tooltip(
@@ -337,58 +325,33 @@ class UpNextPreview extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(_pad), padding: const EdgeInsets.all(_pad),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final item = queue.items.value.firstOrNull; final queued = queue.items.value.firstOrNull;
final episode = queue.shownContinuation.value.firstOrNull; final item = queued ?? queue.continues.value;
final warm = queue.warm.value; final warm = queue.warm.value;
const picture = _width - 2 * _pad; const picture = _width - 2 * _pad;
if (item != null) { if (item == null) return const SizedBox.shrink();
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1; final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null; final ready = warm?.id == item.id ? warm!.state : null;
return Column( final kicker = queued == null
mainAxisSize: MainAxisSize.min, ? 'CONTINUES WITH'
crossAxisAlignment: CrossAxisAlignment.start, : more > 0
children: [ ? 'UP NEXT · $more MORE AFTER IT'
QueueThumb(queue: queue, item: item, width: picture, radius: 8), : 'UP NEXT';
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;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8), QueueThumb(queue: queue, item: item, width: picture, radius: 8),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker), Text(kicker, style: _kicker),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title), Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub), if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
if (overview.isNotEmpty) ...[ const SizedBox(height: 6),
const SizedBox(height: 6), _MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
Text(overview, _footer(ready, ready == null ? performerNote(queue, item, r) : null),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
],
_footer(ready, null),
], ],
); );
}), }),
@@ -481,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget {
final QueueController queue; final QueueController queue;
final EpisodeLink episode; final EpisodeLink episode;
final double width; final double width;
final double radius; const _EpisodeThumb({required this.queue, required this.episode, required this.width});
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
final client = queue.coord.jellyfin.clientFor(e.pick.serverId); final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(6),
child: SizedBox( child: SizedBox(
width: width, width: width,
height: width * 9 / 16, height: width * 9 / 16,
@@ -838,6 +800,7 @@ class _QueuePanelState extends State<QueuePanel> {
), ),
child: SignalBuilder(builder: (context) { child: SignalBuilder(builder: (context) {
final items = _ordered(_q.items.value); final items = _ordered(_q.items.value);
final auto = _q.continues.value;
final cont = _q.shownContinuation.value; final cont = _q.shownContinuation.value;
final warm = _q.warm.value; final warm = _q.warm.value;
final r = _q.coord.serverState.value; final r = _q.coord.serverState.value;
@@ -848,7 +811,7 @@ class _QueuePanelState extends State<QueuePanel> {
children: [ children: [
_header(items.length), _header(items.length),
Flexible( Flexible(
child: items.isEmpty && cont.isEmpty && _adding.isEmpty child: items.isEmpty && auto == null && cont.isEmpty && _adding.isEmpty
? _empty() ? _empty()
: ListView( : ListView(
shrinkWrap: true, shrinkWrap: true,
@@ -900,7 +863,9 @@ class _QueuePanelState extends State<QueuePanel> {
adding: a, adding: a,
onDismiss: () => setState(() => _adding.remove(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<QueuePanel> {
), ),
); );
List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [ /// "Continues with": the continuation everyone shares ([auto], Clear
/// drops it for everyone), then the series' further episodes known here.
List<Widget> _continuation(QueueItem? auto, List<EpisodeLink> cont, bool alone, {WarmState? warm}) => [
Padding( Padding(
padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0), padding: EdgeInsets.fromLTRB(12, alone ? 0 : 8, 2, 0),
child: Row( child: Row(
@@ -964,15 +931,17 @@ class _QueuePanelState extends State<QueuePanel> {
const Text('CONTINUES WITH', const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)), style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
TextButton( if (auto != null)
key: const ValueKey('queue-clear-continuation'), TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), key: const ValueKey('queue-clear-continuation'),
onPressed: _q.clearContinuation, style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
child: const Text('Clear', style: TextStyle(fontSize: 12)), 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), 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 { class _ContinuationRow extends StatefulWidget {
final EpisodeLink episode; final EpisodeLink episode;
final QueueController queue; final QueueController queue;
@@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
// -------------------------------------------------------------- end card // -------------------------------------------------------------- end card
/// When the media ended with Up next waiting: its picture, title and who /// When the media ended with something next (Up next's first item, else
/// added it, the countdown with Cancel, and Play now. /// the continuation): its picture, title and who added it, the server's
/// countdown with Cancel (for everyone), and Play now.
class UpNextCard extends StatelessWidget { class UpNextCard extends StatelessWidget {
final QueueController queue; final QueueController queue;
final UpNextCountdown countdown; final UpNextCountdown countdown;
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
final left = c.secondsLeft; final left = c.secondsLeft;
final r = queue.coord.serverState.value; final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId; 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( return AppGlass(
key: const ValueKey('up-next-card'), key: const ValueKey('up-next-card'),
child: SizedBox( child: SizedBox(
@@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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)), style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(item.title, Text(item.title,
@@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
tween: Tween(end: left / autoAdvanceSecs), tween: Tween(end: left / RelayConstants.advanceSecs),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
builder: (context, v, _) => LinearProgressIndicator( builder: (context, v, _) => LinearProgressIndicator(
value: v, value: v,
@@ -220,8 +220,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'), key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'), 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; ' subtitle: const Text('When you put up an episode (Jellyfin, or a file of a show in Files), the next one '
'anyone can cancel it'), 'continues it after a 10 s countdown everyone sees; anyone can cancel it'),
value: s.autoAdvance.value, value: s.autoAdvance.value,
onChanged: s.setAutoAdvance, onChanged: s.setAutoAdvance,
), ),
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../jellyfin/session_media.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; import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session player. Plain widgets fed by the // 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<double>(
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 /// This viewer cannot play the Jellyfin media: say why and offer to link
/// an account (never a silent stall). /// an account (never a silent stall).
class JellyfinBlockedCard extends StatelessWidget { class JellyfinBlockedCard extends StatelessWidget {
+2 -1
View File
@@ -391,7 +391,8 @@ class Connection {
void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source); void sendReady(bool ready, {String source = 'manual'}) => client.sendReady(ready, source: source);
void sendForce(String target, bool ready) => client.sendForce(target, ready); void sendForce(String target, bool ready) => client.sendForce(target, ready);
void sendSeek(double position, {int seq = 0}) => client.sendSeek(position, seq: seq); 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 sendQueue(QueueIntent intent) => client.sendQueue(intent);
void sendReport({ void sendReport({
required double position, required double position,
@@ -446,6 +446,37 @@ class MediaPipeline {
await playback.stop(); 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<String, String> 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). /// The media was settled here (opened, failed, nothing to open).
void settled(int mediaId) { void settled(int mediaId) {
if (_mediaId == mediaId) settledMediaId.value = mediaId; if (_mediaId == mediaId) settledMediaId.value = mediaId;
@@ -187,6 +187,12 @@ Notice _plain(AppEvent e) => switch (e) {
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor), category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
MemberReturned(:final feed) => MemberReturned(:final feed) =>
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor), 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( MediaSwapped(:final feed) => Notice(
category: NoticeCategory.upNext, category: NoticeCategory.upNext,
log: feed, log: feed,
+2 -2
View File
@@ -12,8 +12,8 @@ const presencePolicy = PresencePolicy();
bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter); bool countsForReady(MemberView m) => m.error.isEmpty && presencePolicy.countsFor(m.presence, Purpose.readyCounter);
/// Connected and watching (`in` or `joining`): can do things for the /// Connected and watching (`in` or `joining`): can do things for the
/// party now (put up an Up next item, the auto-advance swap). /// party now (put up an Up next item; core's [isPresentMember]).
bool isPresent(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining; bool isPresent(MemberView m) => isPresentMember(m);
/// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]). /// Nobody but [selfId] is connected in [members] (D9, core's [isAlone]).
bool aloneIn(List<MemberView> members, String selfId) => bool aloneIn(List<MemberView> members, String selfId) =>
@@ -23,6 +23,7 @@ import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart' show NoopSfxService, SfxService; import '../sfx/sfx.dart' show NoopSfxService, SfxService;
import '../state/settings.dart'; import '../state/settings.dart';
import '../watch/watch_tracker.dart';
import '../sync/avatar_cache.dart'; import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/user_seek.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). /// (Jellyfin-style links carry api_key there and names are broadcast).
String linkDisplayName(String url) => linkName(url); 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 /// "Unready when the video changes": a state with a newer [mediaId] than
/// the last one this session saw ([lastMediaId], -1 before the first) while /// 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 /// 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`), /// What a local file is (`media.ref`, from the library: `ShowMatcher`),
/// set by the composition root; null: files go up without one. /// set by the composition root; null: files go up without one.
MediaRef? Function(String path)? fileRef; 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<LocalNext?> 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). /// The app's yt-dlp and link probes (tests pass their own).
final LinkResolver ytDlp; final LinkResolver ytDlp;
/// The link to the server (socket, reconnects, clock offset, its /// 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 /// else the last two minutes); the pill shows in it only while the
/// controls are up. /// controls are up.
final nextPromptVisible = signal(false); final nextPromptVisible = signal(false);
/// End-of-episode countdown; null when none runs here.
final autoAdvance = signal<AutoAdvance?>(null);
/// The skip pill for the current position ([skipOfferAt]: Skip OP, /// The skip pill for the current position ([skipOfferAt]: Skip OP,
/// Skip ED, or Skip 1:30 without opening info or chapters); null while nothing /// 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 // 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. // 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`). // [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; AniListClient? _aniList;
/// This member serves the current media's bytes through the server. /// This member serves the current media's bytes through the server.
Signal<bool> get relaying => media.feeder.relaying; Signal<bool> get relaying => media.feeder.relaying;
@@ -317,8 +310,16 @@ class SessionCoordinator {
JellyfinProgressReporter? _reporter; JellyfinProgressReporter? _reporter;
PlayerSnapshot? _lastSnap; PlayerSnapshot? _lastSnap;
bool _jfToastShown = false; bool _jfToastShown = false;
Timer? _advanceTimer; // The media whose end was recorded as a watch checkpoint.
int _advanceDoneFor = -1; 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 = ''; String _blockedKey = '';
bool _jellyfinListening = false; bool _jellyfinListening = false;
// The mediaId [playerError] belongs to, and the player's duration when // The mediaId [playerError] belongs to, and the player's duration when
@@ -337,7 +338,9 @@ class SessionCoordinator {
JellyfinAccounts? jellyfin, JellyfinAccounts? jellyfin,
TrackPrefs? trackPrefs, TrackPrefs? trackPrefs,
LinkResolver? ytDlp, LinkResolver? ytDlp,
PlayerHost? players}) { PlayerHost? players,
AppSettings? settings,
WatchTracker? watch}) {
final n = notifier ?? Notifier(sfx: sfx); final n = notifier ?? Notifier(sfx: sfx);
final links = ytDlp ?? AppYtDlp.instance; final links = ytDlp ?? AppYtDlp.instance;
return SessionCoordinator._( return SessionCoordinator._(
@@ -347,6 +350,8 @@ class SessionCoordinator {
jellyfin: jellyfin ?? JellyfinAccounts.instance, jellyfin: jellyfin ?? JellyfinAccounts.instance,
trackPrefs: trackPrefs ?? TrackPrefs(), trackPrefs: trackPrefs ?? TrackPrefs(),
media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links), 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.ytDlp,
required this.jellyfin, required this.jellyfin,
required this.trackPrefs, required this.trackPrefs,
required this.media}) { required this.media,
required this.settings,
required this.watch}) {
connection connection
..onWelcome = _onWelcome ..onWelcome = _onWelcome
..caps = () => caps; ..caps = () => caps;
@@ -614,7 +621,10 @@ class SessionCoordinator {
/// three viewers readying at once). A state that arrives meanwhile /// three viewers readying at once). A state that arrives meanwhile
/// replaces any still waiting. /// replaces any still waiting.
void _onState(StateBroadcast r) { void _onState(StateBroadcast r) {
final hold = _restore;
if (hold != null && r.media != null) _landRestore(hold, r);
serverState.value = r; serverState.value = r;
_stateSelf = connection.memberId;
if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing); if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing);
_toastMemberErrors(r); _toastMemberErrors(r);
_warnSlowSource(r); _warnSlowSource(r);
@@ -641,6 +651,9 @@ class SessionCoordinator {
} }
Future<void> _applyState(StateBroadcast r) async { Future<void> _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); _trackSelfReady(r);
if (_lastAway) { if (_lastAway) {
// A newer media than the one this member serves: that relay ended. // 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 // A ready restored by a reconnect may not have echoed yet: unready
// anyway (it was sent first, so this one lands last). // anyway (it was sent first, so this one lands last).
if (unreadyForNewMedia( if (unreadyForNewMedia(
enabled: AppSettings.instance.unreadyOnSwap.peek(), enabled: settings.unreadyOnSwap.peek(),
lastMediaId: last, lastMediaId: last,
mediaId: r.mediaId, mediaId: r.mediaId,
ready: self.ready || _restoringReady)) { ready: self.ready || _restoringReady)) {
@@ -834,19 +847,54 @@ class SessionCoordinator {
_sendReport(r); _sendReport(r);
if (!_lastAway) _checkSeekLanded(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 (connection.token.isNotEmpty && r != null && _playerReady && !_lastAway) unawaited(_tickJellyfin(r));
if (r != null && online) _readyAfterJoinBack(r, now); if (r != null && online) _readyAfterJoinBack(r, now);
_tickAutoSitOut(r, now, online: online); _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). /// Auto sit-out's second (only while online: the state is current).
void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) { void _tickAutoSitOut(StateBroadcast? r, DateTime now, {required bool online}) {
final step = autoSitOut.tick(now, final step = autoSitOut.tick(now,
self: online ? selfMember.peek()?.presence : null, self: online ? selfMember.peek()?.presence : null,
playing: r?.transport == 'playing', playing: r?.transport == 'playing',
alone: r == null || aloneIn(r.members, connection.memberId), alone: r == null || aloneIn(r.members, connection.memberId),
enabled: AppSettings.instance.autoSitOut.peek()); enabled: settings.autoSitOut.peek());
switch (step) { switch (step) {
case AutoAway(:final reason): case AutoAway(:final reason):
_log.info('auto sit-out: away (${reason.name})'); _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 /// [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). /// 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. // A swap sent into a dropped link would be lost without a word.
if (!connection.state.peek().online) { if (!connection.state.peek().online) {
notify(const ActionFailed('Reconnecting: put it up again once back')); 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 // Putting something up means watching it: back first. Not for Up next
// coming up by itself: whoever puts it up may be sitting out. // coming up by itself: whoever puts it up may be sitting out.
final self = selfMember.peek(); 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(); autoSitOut.manual();
connection.sendPresence(const PresenceIntent(state: Presence.watching)); connection.sendPresence(const PresenceIntent(state: Presence.watching));
} }
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art); _pendingArt =
connection.sendSwap(media, queueId: queueId); (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 /// An Up next item put up as queued, for one this app cannot build
@@ -1107,12 +1156,20 @@ class SessionCoordinator {
_pendingArt = null; _pendingArt = null;
final link = pending.link; final link = pending.link;
_ownLink = link == null || link.isEmpty ? null : (r.mediaId, 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)); unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art));
} }
/// The link this app put up as [StateBroadcast.mediaId] (passed on or /// The link this app put up as [StateBroadcast.mediaId] (passed on or
/// fetched by the server, so not in the state). /// fetched by the server, so not in the state).
(int, String)? _ownLink; (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 /// 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 /// 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 /// reason in plain words unless [anyway]. Returns an error text, or null
/// when sent. /// when sent.
Future<String?> swapLink(String url, Future<String?> 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(); markInput();
final media = await mediaForLink(jellyfin, url); final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) { if (media.jellyfin != null) {
this.media.expectOrigin(JellyfinOrigin(url)); 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; return null;
} }
LinkProbe? probe; LinkProbe? probe;
@@ -1192,7 +1249,7 @@ class SessionCoordinator {
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
final what = linkMediaRef(name); final what = linkMediaRef(name);
if (relay == LinkRelay.direct) { 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; return null;
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
@@ -1227,7 +1284,7 @@ class SessionCoordinator {
} }
this.media.expectOrigin(LinkOrigin(url, probe, y)); this.media.expectOrigin(LinkOrigin(url, probe, y));
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what), _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; return null;
} }
// The server reads it with its own yt-dlp; only a page this app knows // The server reads it with its own yt-dlp; only a page this app knows
@@ -1243,7 +1300,8 @@ class SessionCoordinator {
url: url, url: url,
relay: const MediaRelay(fetch: true), relay: const MediaRelay(fetch: true),
ref: what), ref: what),
queueId: queueId); queueId: queueId,
restore: restore);
return null; return null;
} }
@@ -1257,23 +1315,24 @@ class SessionCoordinator {
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref, /// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account. /// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media, {String? queueId}) { void swapJellyfin(JellyfinMedia media, {String? queueId, SwapRestore? restore}) {
markInput(); markInput();
this.media.expectOrigin(const JellyfinOrigin(null)); 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 /// 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 /// else's goes through the server's relay. Returns an error text, or null
/// when sent. /// when sent.
Future<String?> swapFile(String path, {String? queueId}) async { Future<String?> swapFile(String path, {String? queueId, SwapRestore? restore}) async {
markInput(); markInput();
final what = fileRef?.call(path); final what = fileRef?.call(path);
final host = hostFile; final host = hostFile;
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
if (file == null) return 'file not found — check the 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; return null;
} }
final file = File(path); final file = File(path);
@@ -1285,7 +1344,9 @@ class SessionCoordinator {
duration: 0, duration: 0,
relay: const MediaRelay(), relay: const MediaRelay(),
ref: what), ref: what),
queueId: queueId); file: path,
queueId: queueId,
restore: restore);
return null; return null;
} }
@@ -1302,9 +1363,17 @@ class SessionCoordinator {
return null; 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() { void playNextEpisode() {
final n = nextEpisode.peek(); 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() { void playPreviousEpisode() {
@@ -1312,16 +1381,6 @@ class SessionCoordinator {
if (p != null) swapJellyfin(p.pick); 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 // ------------------------------------------------------------- jellyfin
/// News for the user, from the session or anywhere in the app (see /// News for the user, from the session or anywhere in the app (see
@@ -1342,10 +1401,6 @@ class SessionCoordinator {
nextEpisode.value = null; nextEpisode.value = null;
nextPromptVisible.value = false; nextPromptVisible.value = false;
playback.segments.value = const []; playback.segments.value = const [];
_advanceTimer?.cancel();
_advanceTimer = null;
_advanceDoneFor = -1;
autoAdvance.value = null;
} }
/// The current item is being replaced: Stopped goes out (not awaited: /// The current item is being replaced: Stopped goes out (not awaited:
@@ -1449,6 +1504,12 @@ class SessionCoordinator {
if (gen != _jfGen) return; if (gen != _jfGen) return;
final before = await userDataF; final before = await userDataF;
if (gen != _jfGen) return; 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( final reporter = JellyfinProgressReporter(
client: plan.client, client: plan.client,
itemId: ref.itemId, itemId: ref.itemId,
@@ -1456,6 +1517,7 @@ class SessionCoordinator {
playSessionId: plan.info.playSessionId, playSessionId: plan.info.playSessionId,
playedBefore: before?.played ?? false, playedBefore: before?.played ?? false,
resumeTicksBefore: before?.playbackPositionTicks ?? 0, resumeTicksBefore: before?.playbackPositionTicks ?? 0,
watch: watch.mediaId == r.mediaId ? watch.session : null,
onUnauthorized: () => jellyfin.markExpired(ref.serverId), onUnauthorized: () => jellyfin.markExpired(ref.serverId),
onStopped: () => jellyfin.watchedChanged(ref.serverId), onStopped: () => jellyfin.watchedChanged(ref.serverId),
onError: (e) { onError: (e) {
@@ -1545,7 +1607,7 @@ class SessionCoordinator {
final next = nextEpisode.peek(); final next = nextEpisode.peek();
final duration = playback.durationSecs; final duration = playback.durationSecs;
var show = false; 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 item = (jellyfinPlan.peek() as OwnPlay?)?.item;
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()), final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
endingStart: playback.ending.peek()?.start); endingStart: playback.ending.peek()?.start);
@@ -1554,9 +1616,8 @@ class SessionCoordinator {
nextPromptVisible.value = show; nextPromptVisible.value = show;
} }
/// Per state: played to the end -> Stopped (Jellyfin marks it watched) /// Per state: played to the end -> Stopped (with the watched rule's
/// and the auto-advance countdown; seeked back from the end -> Playing /// verdict); seeked back from the end -> Playing again.
/// again.
void _jellyfinTransport(StateBroadcast r) { void _jellyfinTransport(StateBroadcast r) {
final ref = _jfRef; final ref = _jfRef;
if (ref == null || r.mediaId != _jfMediaId) return; if (ref == null || r.mediaId != _jfMediaId) return;
@@ -1571,48 +1632,7 @@ class SessionCoordinator {
reporter.start(snap); reporter.start(snap);
} }
} }
if (!finished) { if (finished) nextPromptVisible.value = false;
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);
} }
String _accountKey(String serverId) => String _accountKey(String serverId) =>
@@ -1667,11 +1687,18 @@ class SessionCoordinator {
final tokenUrl = rejoin.oldToken.isNotEmpty && final tokenUrl = rejoin.oldToken.isNotEmpty &&
rejoin.oldToken != welcome.token && rejoin.oldToken != welcome.token &&
(_openUrl?.contains('/${rejoin.oldToken}/') ?? false); (_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(); _forgetMedia();
} else if (!rejoin.sameServer && _mediaId != -1) { } else if (!rejoin.sameServer && _mediaId != -1) {
// The same media under a restarted server's numbering: the player // 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; _mediaId = now.mediaId;
media.renumber(now.mediaId); media.renumber(now.mediaId);
_seenMediaId = now.mediaId; _seenMediaId = now.mediaId;
@@ -1679,7 +1706,7 @@ class SessionCoordinator {
// The intent the server lost: ready (unless the video changed and the // The intent the server lost: ready (unless the video changed and the
// user unreadies then), and sitting out. // user unreadies then), and sitting out.
var wantReady = _lastReady && !_lastAway; var wantReady = _lastReady && !_lastAway;
if (changed && wantReady && _seenMediaId >= 0 && AppSettings.instance.unreadyOnSwap.peek()) { if (changed && !restoring && wantReady && _seenMediaId >= 0 && settings.unreadyOnSwap.peek()) {
wantReady = false; wantReady = false;
_lastReady = 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<void> _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 /// Whether two states' `media` are the same media (a restarted server
/// counts its `mediaId` from 1 again). /// counts its `mediaId` from 1 again).
static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) { static bool _sameMedia(Map<String, Object?>? a, Map<String, Object?>? b) {
@@ -1746,10 +1892,18 @@ class SessionCoordinator {
_seekTimer?.cancel(); _seekTimer?.cancel();
_seekTimer = null; _seekTimer = null;
_seekGate.reset(); _seekGate.reset();
watch.close();
_watchEndFor = -1;
_restore = null;
_restoreTimer?.cancel();
_restoreTimer = null;
queue.restoreFrom(-1);
await _stopJellyfin(); await _stopJellyfin();
media.reset(); media.reset();
_pendingArt = null; _pendingArt = null;
_ownLink = null; _ownLink = null;
_ownFile = null;
ownFile.value = null;
_playerReady = false; _playerReady = false;
try { try {
await media.release(); await media.release();
+12 -2
View File
@@ -278,7 +278,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'seek': case 'seek':
return (Icons.fast_forward, scheme.primary, 'seeked'); return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap': 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': case 'gone':
return (Icons.cloud_off, Colors.orange, 'lost signal'); return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'return': case 'return':
@@ -296,6 +296,9 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
case 'queue-skip': case 'queue-skip':
final why = f.reason.isEmpty ? '' : ' · ${f.reason}'; final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why'); 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': case 'fetch-fail':
return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}"); return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}");
default: default:
@@ -304,9 +307,16 @@ Future<void> _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) { (IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'), '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'), _ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
}; };
+2 -16
View File
@@ -419,16 +419,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
}); });
} }
/// The action area's right end: the end-of-media card (Up next, else the /// The action area's right end: the end-of-media card (Up next, else
/// next episode; it stays up), else the pills, shown only with the /// the continuation; it stays up), else the pills, shown only with the
/// controls ([PillGate]). /// controls ([PillGate]).
Widget _actionSide() { Widget _actionSide() {
final coord = _coord; final coord = _coord;
return SignalBuilder( return SignalBuilder(
builder: (context) { builder: (context) {
final next = coord.nextEpisode.value; final next = coord.nextEpisode.value;
final countdown = coord.autoAdvance.value;
// Up next goes before the next episode.
final upNext = coord.queue.countdown.value; final upNext = coord.queue.countdown.value;
final finished = coord.serverState.value?.transport == 'finished'; final finished = coord.serverState.value?.transport == 'finished';
final pill = coord.nextPromptVisible.value; final pill = coord.nextPromptVisible.value;
@@ -437,18 +435,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (upNext != null && finished) { if (upNext != null && finished) {
return ControlsHoldRegion(hold: _hold, child: UpNextCard(queue: coord.queue, countdown: upNext)); 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(); if (!showNext && skip == null) return const SizedBox.shrink();
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+1 -1
View File
@@ -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()), const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
]; ];
case SettingsSection.notifications: case SettingsSection.notifications:
+3 -1
View File
@@ -59,7 +59,9 @@ class AppSettings {
final hwdecMode = signal(HwdecMode.auto); final hwdecMode = signal(HwdecMode.auto);
final hwdecCustom = signal(''); final hwdecCustom = signal('');
final autoCheckUpdates = signal(true); 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); final autoAdvance = signal(true);
/// Session: send ready=false when the video changes (a swap keeps the /// Session: send ready=false when the video changes (a swap keeps the
/// server's ready flags). /// server's ready flags).
+2 -2
View File
@@ -288,8 +288,8 @@ class SyncClient {
_send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson()); _send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson());
} }
void sendSwap(HelloMedia media, {String? queueId}) { void sendSwap(HelloMedia media, {String? queueId, SwapRestore? restore}) {
_send(SwapIntent(media: media, queueId: queueId).toJson()); _send(SwapIntent(media: media, queueId: queueId, restore: restore).toJson());
} }
/// One change to Up next (see [QueueIntent]). /// One change to Up next (see [QueueIntent]).
@@ -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<String, Object?>? 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<String, Object?>.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<String, Object?> 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<String, Object?> _memory = {};
/// False: memory only (tests).
final bool persist;
Box? _box;
Future<void>? _load;
final revision = signal(0);
WatchHistory({this.persist = true});
Future<void> 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 = <String, Object?>{..._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;
}
}
@@ -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<String, Object?>? 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<WatchRecord>.broadcast();
/// Every closed watch (peeks too): the device history takes files and
/// links; AniList progress later listens here.
Stream<WatchRecord> 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<void> dispose() => _records.close();
}
@@ -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 { 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')); final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start(); await server.start();
+42 -23
View File
@@ -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:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; 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/playback/media_marks.dart';
import 'package:starsync/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.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 // 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): // 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() { void main() {
test( 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)), timeout: const Timeout(Duration(minutes: 4)),
() async { () async {
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
@@ -296,6 +296,11 @@ void main() {
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos'); 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. // Pause (A unreadies): an immediate Progress with IsPaused.
a.toggleReady(); a.toggleReady();
await _poll(() => !a.playback.player.state.playing, 'held'); await _poll(() => !a.playback.player.state.playing, 'held');
@@ -304,7 +309,7 @@ void main() {
_kind(s) == 'Progress' && s.body?['IsPaused'] == true), _kind(s) == 'Progress' && s.body?['IsPaused'] == true),
'paused progress'); 'paused progress');
a.toggleReady(); 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 // Account holders see the "Next episode" prompt (this item is
// shorter than its 2 min lead); the guest cannot put it up. // 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), _kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
'track change reported'); 'track change reported');
// Near the end: finished -> Stopped, then one auto-advance swap. // Alice put the episode up and has the next one: it continues it,
a.seekTo(16); // 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', await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
timeout: const Duration(seconds: 20)); timeout: const Duration(seconds: 40));
final finishedId = a.serverState.peek()!.mediaId; final finishedId = a.serverState.peek()!.mediaId;
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null, await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
'both account holders count down'); 'everyone sees the countdown');
expect(b.autoAdvance.peek(), isNull); expect(a.serverState.peek()!.advance!.id, auto.id);
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper]; expect(a.queue.countdown.peek()!.item.id, auto.id);
expect(swappers.where((s) => s), hasLength(1),
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
expect(a.autoAdvance.peek()!.swapper, isTrue);
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced', 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)); await Future.delayed(const Duration(seconds: 3));
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap'); expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2); 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), await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
'all opened the next episode'); 'all opened the next episode');
expect(b.jellyfinPlan.peek(), isA<RelayPlay>()); expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
@@ -383,13 +393,17 @@ void main() {
expect(ep1.last, 'Stopped'); expect(ep1.last, 'Stopped');
expect(ep1.where((k) => k == 'Stopped'), hasLength(1)); expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1); final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
// Part of it watched, then a seek to the end: not watched (see // Alice watched it through (time In, see WatchSession): Stopped
// WatchSession). A resume point capped at 85 % of the 20 s, so // without a position, then marked played.
// Jellyfin cannot mark it played on its own. expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000), bool markedPlayed(String token) => jelly.calls.any((c) =>
reason: 'part way: resume point under 90 %, so not marked played'); c.method == 'POST' &&
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse, c.path == '/UserPlayedItems/$_ep1' &&
reason: 'seeking to the end is not watching'); _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(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
expect(ep2.first, 'Playing'); expect(ep2.first, 'Playing');
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped'); 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 accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore()); final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false); 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())); final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
try { try {
await a.connect(ConnectParams( await a.connect(ConnectParams(
+62 -42
View File
@@ -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 'dart:convert';
import 'package:flutter/gestures.dart'; 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/jellyfin/track_prefs.dart';
import 'package:starsync/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/session/jellyfin_widgets.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/delivery.dart';
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart'; 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', () { group('progress', () {
late JellyfinAccounts accounts; late JellyfinAccounts accounts;
late DateTime now; late DateTime now;
late WatchSession watch;
late JellyfinProgressReporter reporter; late JellyfinProgressReporter reporter;
final errors = <Object>[]; final errors = <Object>[];
var unauthorized = 0; var unauthorized = 0;
@@ -224,27 +226,35 @@ void main() {
now = DateTime(2026, 1, 1, 20); now = DateTime(2026, 1, 1, 20);
errors.clear(); errors.clear();
unauthorized = 0; unauthorized = 0;
watch = WatchSession();
reporter = JellyfinProgressReporter( reporter = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!, client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId, itemId: episodeId,
source: fixtureSource(), source: fixtureSource(),
playSessionId: playSessionId, playSessionId: playSessionId,
watch: watch,
clock: () => now, clock: () => now,
onError: errors.add, onError: errors.add,
onUnauthorized: () => unauthorized++, 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<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true)) Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
.then((_) {}); .then((_) {});
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async { 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). // 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)); 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}) { 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)); tick(secs, pos, paused: paused, aid: aid, sid: sid);
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
}
at(1, 0); // resume -> Progress at(1, 0); // resume -> Progress
at(2, 1); at(2, 1);
@@ -282,15 +292,14 @@ void main() {
'MediaSourceId': episodeId, 'MediaSourceId': episodeId,
'PlaySessionId': playSessionId, 'PlaySessionId': playSessionId,
'PositionTicks': 0, '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); expect(errors, isEmpty);
}); });
test('watched to the end: Stopped without a position, then marked played', () async { test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false)); reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) { for (var i = 0; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i)); tick(i, i.toDouble());
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
} }
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3), 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'); 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 { test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter( final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!, client: accounts.clientFor(fixtureServerId)!,
@@ -376,21 +398,6 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); 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', () { test('member route: via server only for relay viewers of the current item', () {
DeliveryRoute? route(MemberView m, {bool relay = true}) => DeliveryRoute? route(MemberView m, {bool relay = true}) =>
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay); routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
@@ -508,23 +515,36 @@ void main() {
const JellyfinMedia( const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes')); serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async { testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
var play = 0, cancel = 0; (tester) async {
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard( final queue = _graph.coordinator.queue;
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++)))); const item = QueueItem(
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget); id: 'q4',
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing); media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
await tester.tap(find.byKey(const ValueKey('play-next'))); how: QueueHow.jellyfin,
expect(play, 1); 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( // Cancelled (for everyone): Play now only.
next: next, await tester.pumpWidget(scoped(_graph, app(UpNextCard(
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false), queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
onPlayNow: () => play++, expect(find.text('Next'), findsOneWidget);
onCancel: () => cancel++)))); expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
expect(find.text('Next episode in 7 s'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel'))); // Ran out while the one who puts it up is gone: the card says so.
expect(cancel, 1); 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 { testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
+56 -52
View File
@@ -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:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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'; import 'package:starsync/src/queue/queue_logic.dart';
MemberView member(String id, import 'show_matcher_test.dart' show FakeAniList;
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView( // What's next on the app: an item's words, and what continues a file of
id: id, // a local show (who puts an item up and the countdown are core's and the
name: name.isEmpty ? id : name, // server's: core `whats_next_test`, `queue_test`).
ready: false,
inSet: true,
buffered: 0,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
jf: jf);
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem( QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1', id: 'q1',
media: how == QueueHow.jellyfin media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
? 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, how: how,
title: 'x', title: 'x',
by: by, by: by,
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
); );
void main() { 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', () { test('labels', () {
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob'); expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file"); 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(3725), '1:02:05');
expect(queueClock(65), '1:05'); expect(queueClock(65), '1:05');
expect(queueClock(0), ''); 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<String> 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);
});
});
} }
+8 -1
View File
@@ -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/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.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 // 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 // end, over real mpv and a real sync server: two Jellyfin account holders
@@ -244,8 +245,14 @@ void main() {
final hosts = <SessionCoordinator, MediaKitHost>{}; final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) { SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init(); 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( 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; ..media.preloadEnabled = preload;
hosts[c] = host; hosts[c] = host;
return c; return c;
+1 -1
View File
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink. /// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
/// Lower a number when a use goes; never raise one or add a class. /// Lower a number when a use goes; never raise one or add a class.
const baseline = { const baseline = {
'AppSettings': 55, 'AppSettings': 49,
'JellyfinAccounts': 10, 'JellyfinAccounts': 10,
'UpdateController': 8, 'UpdateController': 8,
'Profile': 8, 'Profile': 8,
+116
View File
@@ -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<WatchRecord> 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<String, Object?> 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<void>.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<void>.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<void>.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);
});
}
+146
View File
@@ -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<String> _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<void> _poll(FutureOr<bool> 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<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name, {Future<LocalNext?> 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');
});
}
+161 -70
View File
@@ -7,9 +7,9 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule | | 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 | | `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 | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | | | `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 | | `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` (`app/app_graph.dart`) is built once in `main.dart`
(`AppGraph.build`) and constructs and disposes the app's systems: the (`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 `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`, `SessionCoordinator` (handed the settings and the watch tracker), the
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's connect form and recents (`ConnectStore`, `state/connect_prefs.dart`),
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the `YourServer`, `Navigation`, the library's matches (`ShowMatcher`,
coordinator as `fileRef`); it wires them "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 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 has no logic or signals of its own. Widgets reach it through one
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in `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 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 resumed needs nothing re-sent; a new member (grace over, server
restarted) gets the app's ready and sitting out again, and the media 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 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 even with nothing playing. Cancel during setup aborts the join. Leave
and closing the window send `bye` (see "App close" under Desktop UI); and closing the window send `bye` (see "App close" under Desktop UI);
Update and restart does not. 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** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings
"Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and "Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and
no input for 5 min sets this member `away` (reason `idle`); minimised 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 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 (bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
back from the end sends Playing again. Reports carry `PositionTicks` 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` = stream indices (`jellyfinIndexForMpvTrack`, `SubtitleStreamIndex -1` =
off). They go out in order on one chain; failures are logged, one quiet 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 toast per session, playback is never touched; a 401 marks the account
expired and ends reporting. expired and ends reporting.
- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`, - **Watched on Jellyfin**: the watch of the item is the session's (see
pure, meant for any tracker): each opening samples the position every "Watch tracking"); `JellyfinProgressReporter` reads its verdict and
tick; only normal playback counts (forward, at most 2x speed + 1.5 s, never samples itself. Jellyfin decides played from
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
positions alone (10.11): Playing sets a resumable item unplayed, a positions alone (10.11): Playing sets a resumable item unplayed, a
Progress or Stopped position past "max resume" (90 %) marks it played, 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 (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): - played (before, or by the rule at an earlier stop of this opening):
`UserPlayedItems` right after Playing, Progress without `UserPlayedItems` right after Playing, Progress without
`PositionTicks` (leaves the saved position and played state), Stopped `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 there is one); from `nextPromptStart` (the ending's
start when one is known, see "Skip rules", else the last 2 min of the 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 runtime) a floating "Next episode" pill shows while the controls are up
(see "Player controls"). Auto-advance still waits for `finished`. Any (see "Player controls"). Any of them puts that episode up with the
of them swaps to that episode's ref with the clicker's account. Guests clicker's account (through Up next when it is the continuation there).
(relay, blocked) see none of it. At the end, the next episode continues the item by itself (see "What's
- **Auto-advance** (Settings, on by default): at `finished` with a known next"). Guests (relay, blocked) see none of it.
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.
- **Tracks**: the controls' Audio and Subtitles buttons (or ⚙ when - **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` 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 (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 Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file
through `ServerIsolate.setFile` (host) or the relay (`swapFile`). 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 ## Up next
The queue: what plays after the current media. The server holds the list 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 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 memory under it and nothing else leaves it. The adder gets a toast
"Added … to Up next"; everyone else an activity-log line. "Added … to Up next"; everyone else an activity-log line.
- **Who puts an item up** (`queuePerformer`, from `state` alone, so all - **Who puts an item up** (core's `queuePerformer`, from `state` alone,
clients agree): a link passed on or a file, only its adder (`gone`: 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 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 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 own account, else the lowest present member id that does (`jf`, which
lists every working account; nobody: skipped); a link everyone opens lists every working account; nobody: skipped); a link everyone opens
or the server fetches, its adder when present, else the lowest present or the server fetches, its adder when present, else the lowest present
member id (present: `in` or `joining`, `isPresent` in member id (present: `in` or `joining`, `isPresentMember`). An item asked
`session/party.dart`). A skip is a `remove` with the reason from the for that nobody can put up is skipped by the server (see "What's
lowest member id that is not `gone` (`queueJanitor`); the feed's next"); an item whose swap fails is taken off by the app that tried,
`queue-skip` toasts on every screen. with the reason; the feed's `queue-skip` toasts on every screen.
- **Advancing**: at `finished` with Up next waiting, every screen shows - **Advancing**: at `finished` with something next, every screen shows
`UpNextCard` (picture, title, who added it and how; with auto-advance on `UpNextCard` (picture, title, who added it and how; the server's
a 10 s countdown with Cancel, else Play now only); at zero the app that countdown with Cancel while `state.advance` runs, "Waiting for Bob"
puts the first item up does, with a swap naming it (`swap.queueId`), so 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 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 ignored. "Play now" anywhere (a row, the card, the Up next button's ⏭,
now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps `>` / Shift+N) swaps directly from the app that can, else sends `play`
directly from the app that can, else sends `play` and that app reacts and that app reacts to `state.queuePlay`. With nothing up at all, the
to `state.queuePlay`. With nothing up at all, the first item goes up as server asks for the first item as soon as it is added. The swap goes through the usual paths
soon as it is added. The swap goes through the usual paths
(`swapPicked`, `swapJellyfin`; a server-fetched item through (`swapPicked`, `swapJellyfin`; a server-fetched item through
`swapQueued`, without the URL, its picture as the art), so relays, art `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 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. out comes back first; any other queued swap leaves the swapper out.
- **Getting ready** (`QueueController._warm`): every app keeps the one - **Getting ready** (`QueueController._warm`): every app keeps the one
item that plays next ready all the time, its own copy, so a skip, Play 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 now or the end of the media starts it at once. That is the queue's
next item, else, with Up next empty, the first "Continues with" first item (Up next's, else the continuation, which is in the queue
episode (on the apps that have it); while sitting out only the probe for everyone); while sitting out only the probe
of a link this app puts up (its swap reads it). Per of a link this app puts up (its swap reads it). Per
kind: a Jellyfin item is resolved with this app's account kind: a Jellyfin item is resolved with this app's account
(`resolveJellyfinRef`: PlaybackInfo and the item) into (`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 says which): hollow waiting, pulsing getting ready (resolving, then
preloading its first 4 s), filled ready, red failed (tried again every preloading its first 4 s), filled ready, red failed (tried again every
2 minutes). 2 minutes).
- **Continues with**: not queued, computed here like the next episode: - **Continues with**: the continuation (the `auto` item, see "What's
with an own-account Jellyfin episode playing, the next three episodes next"), greyed under Up next for everyone, ▶ plays it now; "Clear"
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next, drops it for everyone (`cancel`). Under it, on an app playing a
"+" adds one. "Clear" hides them for the media playing and keeps its Jellyfin episode with its own account, the series' further episodes
next-episode countdown from starting. (`/Shows/{id}/Episodes`, cached), "+" adds one to Up next.
- **UI**: `UpNextButton` is one of the player's top-bar controls, before - **UI**: `UpNextButton` is one of the player's top-bar controls, before
the window controls (see "Player overlay" under Desktop UI), in the look 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, 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`, 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 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 title, a +n count, ⏭ plays it now; without Up next the continuation
(the episode's image, "NEXT · S1 E8" over its name, ⏭ = next episode); (its picture, "NEXT · S1E8" (its second line) over its name, ⏭ plays
nothing when both are empty. Resting on it 600 ms (popover closed) it); nothing when both are empty. Resting on it 600 ms (popover closed)
shows `UpNextPreview` under it, right edges aligned, in `AppGlass`: shows `UpNextPreview` under it, right edges aligned, in `AppGlass`:
the picture large, title, second line, "length · added by (avatar) the picture large, title, second line, "length · added by (avatar)
name · how" (an episode: series, length, overview), how ready it is 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 title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds
carry it; Up next names a file item by it. Consumers: receivers' now 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 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 ## 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 servers), player (the shared rows below, the controls' choices, custom
shader files), shader files),
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via 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 (what pops up, chat pop-ups, joins and leaves while sitting out or in
the background), updates, shortcuts, about. the background), updates, shortcuts, about.
- **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one - **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`, 16 px from the bottom while they are hidden (`AnimatedPositioned`,
the shell's `ease` / `easeTime`); in it the status pill centred 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 (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 overlapping it) and at the right end the end card (`UpNextCard`, which
`EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED / stays up) or the action pills (Skip OP / ED /
1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in 1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in
the middle. Two surfaces: cards and popovers use `AppGlass` the middle. Two surfaces: cards and popovers use `AppGlass`
(`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline (`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 volume, skip offer, render mode; the shell's fullscreen, chat open and
unread count) and what they do (`togglePlay` = ready, as Space; unread count) and what they do (`togglePlay` = ready, as Space;
`seek` through `player.seek`, the user path; `nudge`, `skipForward`, `seek` through `player.seek`, the user path; `nudge`, `skipForward`,
`skip`, `stepChapter`, `pickChapter`; `next` = Up next's first item, `skip`, `stepChapter`, `pickChapter`; `next` = the queue's first item
else the next episode, `previous`; volume set / step (flashes) / mute; (Up next's, else the continuation), else the next episode, `previous`; volume set / step (flashes) / mute;
`pickTrack`, `pickShader`; time display; fullscreen, chat, change `pickTrack`, `pickShader`; time display; fullscreen, chat, change
video, collapse, leave, reload, mark everyone ready, link). The video, collapse, leave, reload, mark everyone ready, link). The
player keys call the same actions. `ControlsSnapshot` (computed) is player keys call the same actions. `ControlsSnapshot` (computed) is
+20 -9
View File
@@ -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 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), failed resolve against a stand-in yt-dlp (`cookies_test`, D11),
the sync engine (clock, gate, presence and grace, correction, end of 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 (release and folder names, `groupLocal` over a real anime folder's
names: shows across folders, holes, extras, a movie), 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. Jellyfin client parsing against captured fixtures.
- `cd packages/server && dart test` (well under a minute): live servers - `cd packages/server && dart test` (well under a minute): live servers
on loopback: auth and version refusal, identity, takeover and grace on loopback: auth and version refusal, identity, takeover and grace
(`presence_test` on a fake clock), media and relay byte serving with (`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 isolate `serve`, sharing (`share_test`: a listener attached and detached
under the same members and Up next, the host key standing in for the under the same members and Up next, the host key standing in for the
password). password).
@@ -101,7 +104,9 @@ Three suites, run from each package's folder:
fake AniList (`show_matcher_test`), `media.ref` producers, fake AniList (`show_matcher_test`), `media.ref` producers,
shader list, sound engine with a fake backend, invite addresses, the shader list, sound engine with a fake backend, invite addresses, the
version gate, the media plan's table with the Jellyfin relay rule and 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` 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; tests plus `coord_presence`. The whole suite takes a couple of minutes;
run it only before a restructure step lands, otherwise only the files 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 Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`, `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), host, the old one retired, leave releases them all),
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`, `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 `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 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 has sent, timed; a source that drops while a viewer seeks past
the buffer and another joins: no seek loop, "Waiting for", all resume 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 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 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 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 | | 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) | | | 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) | | | 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) | | | 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 A test file whose first line is `// restructure: rewrite in step N` pins
+12 -1
View File
@@ -78,7 +78,13 @@ brings you back.
6. **Swap.** Anyone can change the video by pasting another link. Everybody 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 stops at 0 and the gate runs again for everyone there; ready flags stay
as they were (a setting unreadies you instead). 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 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 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 (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 connected and in the chat, but their app loads nothing and nobody waits
for them until they join back. 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 ## Where video comes from
- **Link**: you paste an http(s) URL. By default your computer opens it and - **Link**: you paste an http(s) URL. By default your computer opens it and
+32 -12
View File
@@ -98,6 +98,7 @@ yet; the restructure step named next to them does.
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}} {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
{"t":"swap","media":{...},"queueId":"q3"} puts up an Up next item {"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, {"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"} "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
{"t":"ping","c":1767225600000} {"t":"ping","c":1767225600000}
@@ -205,6 +206,13 @@ Field notes:
every member's `ready` and puts every member in the set (`inSet`; who 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 counts is up to presence); clients that want to be unready for the new
media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate"). 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 - Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently. Frames unknown frames are ignored silently. Frames
from a socket another hello took over are ignored. 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 `source` (the same while the relay's source is `gone`), `start` (nobody
counts for the gate: someone has to ready up). `members`: the member counts for the gate: someone has to ready up). `members`: the member
ids the gate waits for (empty for `start`). ids the gate waits for (empty for `start`).
- `state.advance` (**reserved**, step 6): the shared countdown to Up next - `state.advance` (omitted when none runs): the shared countdown to Up
(see "Queue"). 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 - `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 (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. 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 the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for
manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`, manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`,
`finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`, `finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`,
`queue-skip`, `advance-cancel` (`target` the dropped continuation's `queue-skip` (actor `server` when the server skipped it), `advance-cancel`
title, if any), `fetch-fail` (the server could not fetch the current (`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. 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 `rename`: `actor` is the new name, `target` the old one. `avatar`: the
actor changed only the picture. `queue-*`: `target` is the item's actor changed only the picture. `queue-*`: `target` is the item's
title, `reason` (skip) why it could not play (see "Queue"). title, `reason` (skip) why it could not play (see "Queue").
@@ -623,7 +632,7 @@ state "queue":[<item>,...], "queuePlay":"q3"
<item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin", <item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin",
"title":"...","sub":"...","duration":213,"thumb":"<hash>", "title":"...","sub":"...","duration":213,"thumb":"<hash>",
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?} "by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved state "advance":{"id":"q3","at":"<server time>","secs":10}
``` ```
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets - `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
@@ -634,7 +643,8 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
item is queued. `title` and `sub` are cleaned like media names and cut 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` to 200 characters (an empty title becomes the media's name); `duration`
(seconds, omitted when unknown) is informational. At most 100 items; (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: - `how` and what `media` must carry:
- `direct`: an http(s) `url`; everyone opens the link. - `direct`: an http(s) `url`; everyone opens the link.
- `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server - `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server
@@ -654,7 +664,10 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
- `play`: moves `id` first and sets `state.queuePlay` to it: a request for - `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 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, 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 - `auto` (with `for`, the `mediaId` it continues): the continuation (the
next episode). Only for the current media (else `err` `bad`), and only 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 from the member who put it up (`media.swapBy`) or, when that member is
@@ -662,12 +675,19 @@ state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
one: a newer one replaces it; it stays after the other items (an `add` 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 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. 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 - `cancel`: drops the `auto` item and the countdown for everyone (feed
`advance-cancel`); nothing to drop, nothing happens. `advance-cancel`); nothing to drop, nothing happens.
- `state.advance` (**reserved**, step 6): the shared countdown when the - `state.advance`: the shared countdown, started when the media finishes
media finishes and an item is first; at `at` + `secs` the server would with an item first (or an item comes first while it is finished);
set `queuePlay` to `id`, holding while its performer or source is `secs` is 10, `at` its start (server clock). A new first item takes it
`gone`. The server never starts one yet; `cancel` and any swap clear 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 - `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 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 ignored (someone put it up first). For a `server` item the swap may
+42 -32
View File
@@ -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. - D11 Login-only sites: error texts say which cookies were used.
**Problems:** **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). - 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 - 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 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, (client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list). 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 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 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. window; check a 1.2.x app gets a clear "update the app" message.
Composition root (in place, ARCHITECTURE "Composition root"): the Composition root (in place, ARCHITECTURE "Composition root"): the
remaining `.instance` singletons move into `AppGraph` as the steps touch 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, JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
AppYtDlp 2, RecentLinks 3). AppYtDlp 2, RecentLinks 3).
Not in the restructure: P25 (ops). 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 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 as a new member). Your server writes its session log even when you are
alone. 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 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 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 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 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 item this member put up re-feeds its relay after an app restart (the
state still names it the source). 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 Left of step 4 (library): a human check of the Files library on the
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the 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 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 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; 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 Wrong show? fixes it. The `library` box keeps every AniList entry seen (no pruning);
(from Recent); no countdown or "Continues with" for files yet (backlog
2). The `library` box keeps every AniList entry seen (no pruning);
AniList's episode titles exist only where it lists streaming episodes. AniList's episode titles exist only where it lists streaming episodes.
Left of step 2: system notifications need a human check (Linux: the Left of step 2: system notifications need a human check (Linux: the
notification shows and a click brings the window up, also on Wayland, 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 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 `bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and
replay, relay `sourceGone` and source resume, lasting `queue[].by`, 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: 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 - `hello.client` optionally derived per server (HMAC of an install
secret and the dialled host:port). secret and the dialled host:port).
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid - 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 seeks are shared, and only real ranges (segments, OP/ED chapters) should
ever be skipped. ever be skipped.
2. **Files**: the next episode of a local show (the library's 2. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
`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
bundled Windows libmpv plays the app's `edl://` URLs (video + audio bundled Windows libmpv plays the app's `edl://` URLs (video + audio
with headers) through media_kit. YouTube: yt-dlp wants a JS runtime with headers) through media_kit. YouTube: yt-dlp wants a JS runtime
(deno) for full extraction. (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 - Thumbnails and scrubbing previews of a relayed media with tracks
should open the video track's URL alone, not the EDL. 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 filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow. 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 uncleanly and comes back" a few times under the full suite (its
predecessor failed about half the time there). 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 install updates and restarts (`watch_together.exe`, a copy of
`starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from `starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from
`%APPDATA%\com.example\watch_together` and Documents, the next update `%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 fullscreen controls, hosting (firewall prompt), in-app update from one
release to the next. The rest of the desktop tests are not in CI. 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: PC -> VPS -> friend, Jellyfin and a file. Then:
- Source takeover: when the source leaves, another member with an - Source takeover: when the source leaves, another member with an
account on that Jellyfin (or the same file: name, size, hash) takes 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) fetch of member-resolved links (the member need not upload or stay)
is worth building. 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 - Video output robustness: if media_kit's hardware path regresses to
software (see ARCHITECTURE, Media: render mode), vendor a software (see ARCHITECTURE, Media: render mode), vendor a
media_kit_video fix that builds the EGL display from GDK's native 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 What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
in a session"): 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 - Sidecar (external) subtitle streams of Jellyfin items: only embedded
tracks are mapped to mpv today. tracks are mapped to mpv today.
+1
View File
@@ -16,6 +16,7 @@ export 'src/media_source.dart';
export 'src/sync_engine.dart'; export 'src/sync_engine.dart';
export 'src/updater.dart'; export 'src/updater.dart';
export 'src/watch_session.dart'; export 'src/watch_session.dart';
export 'src/whats_next.dart';
export 'src/jellyfin/client.dart'; export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart'; export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart'; export 'src/jellyfin/http.dart';
+3 -1
View File
@@ -92,10 +92,12 @@ abstract class RelayConstants {
/// Up next (`queue`): at most this many items; a title or second line /// Up next (`queue`): at most this many items; a title or second line
/// is cut to [maxQueueTextLength] characters; an item's picture is /// is cut to [maxQueueTextLength] characters; an item's picture is
/// capped like a poster ([maxPosterBytes]). A play request nobody acts on /// 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 maxQueueItems = 100;
static const int maxQueueTextLength = 200; static const int maxQueueTextLength = 200;
static const int queuePlayTimeoutSecs = 30; static const int queuePlayTimeoutSecs = 30;
static const int advanceSecs = 10;
/// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's /// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's
/// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at /// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at
+37 -10
View File
@@ -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<String, Object?> 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 { class SwapIntent {
final HelloMedia media; final HelloMedia media;
/// The Up next item this swap puts up (`queue[].id`); the server takes /// 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 /// it out of the queue with the swap, and ignores the swap when it is no
/// longer queued (someone else put it up first). /// longer queued (someone else put it up first).
final String? queueId; 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<String, Object?> toJson() => Map<String, Object?> toJson() => {
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId}; 't': 'swap',
'media': media.toJson(),
if (queueId != null) 'queueId': queueId,
if (restore != null) 'restore': restore!.toJson(),
};
factory SwapIntent.fromJson(Map<String, Object?> json) { factory SwapIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent'); if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
@@ -854,7 +880,8 @@ class SwapIntent {
final queueId = json['queueId']; final queueId = json['queueId'];
return SwapIntent( return SwapIntent(
media: HelloMedia.fromJson(Map<String, Object?>.from(media)), media: HelloMedia.fromJson(Map<String, Object?>.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 /// `state.advance`: the shared countdown to Up next's first item when the
/// first item when the media finishes. At [at] + [secs] (server clock) the /// media finishes. At [at] + [secs] (server clock) the server sets
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears /// `queuePlay` to [id] (holding while its performer or its relay's source
/// it for everyone. The server parses and broadcasts it but never starts /// is `gone`); `{"t":"queue","op":"cancel"}` and a seek clear it for
/// one yet. /// everyone (see `SyncEngine.whatsNext`).
class QueueAdvance { class QueueAdvance {
final String id; final String id;
final DateTime at; final DateTime at;
@@ -1211,7 +1238,7 @@ class StateBroadcast {
final String? queuePlay; final String? queuePlay;
/// Why it is held ([HoldInfo]); null unless `held` with media up. /// Why it is held ([HoldInfo]); null unless `held` with media up.
final HoldInfo? hold; 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; final QueueAdvance? advance;
const StateBroadcast({ const StateBroadcast({
required this.eventId, required this.eventId,
+25 -17
View File
@@ -2,9 +2,9 @@ import 'dart:typed_data';
import 'constants.dart'; import 'constants.dart';
import 'errors.dart'; import 'errors.dart';
import 'presence.dart';
import 'protocol.dart'; import 'protocol.dart';
import 'sync_engine.dart'; import 'sync_engine.dart';
import 'whats_next.dart';
// Up next: what plays after the current media, held by the server for // Up next: what plays after the current media, held by the server for
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the // 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 /// The `queue` messages applied to the engine's [SyncEngine.queue], with
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`, /// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
/// `advance-cancel`). Each returns whether the queue changed; /// `advance-cancel`), then what's next ([SyncEngine.whatsNext]). Each
/// [applyQueueAdd] a refusal for the sender instead (null when added). /// returns whether the queue changed; [applyQueueAdd] a refusal for the
/// sender instead (null when added).
extension EngineQueueOps on SyncEngine { extension EngineQueueOps on SyncEngine {
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An /// [item] is already cleaned by the server (see `cleanQueueAdd`). An
/// [QueueItem.auto] item must continue the current media /// [QueueItem.auto] item must continue the current media
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put /// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the /// it up or, when that member is not `in`, from the first member who is
/// first member who is. /// ([mayAddContinuation]); it adds no feed line (it is no news).
QueueRefusal? applyQueueAdd(String token, QueueItem item, QueueRefusal? applyQueueAdd(String token, QueueItem item,
{bool next = false, String? privateUrl, Uint8List? thumb}) { {bool next = false, String? privateUrl, Uint8List? thumb}) {
final member = members[token]; final member = members[token];
@@ -358,24 +359,27 @@ extension EngineQueueOps on SyncEngine {
if (media == null || item.forMedia != mediaId) { if (media == null || item.forMedia != mediaId) {
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media'); return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
} }
final swapper = members[swapBy]; final views = [for (final m in members.values) m.view(inGate: false)];
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull; if (!mayAddContinuation(member.id, members[swapBy]?.id, views)) {
final allowed = token == swapBy ||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
if (!allowed) {
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it'); 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), final added = queue.add(item.copyWith(by: member.id, byName: member.name),
next: next, privateUrl: privateUrl, thumb: thumb); next: next, privateUrl: privateUrl, thumb: thumb);
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)'); if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
feed.add(FeedMessage( if (!added.auto) {
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now)); feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
}
whatsNext();
return null; return null;
} }
bool applyQueueMove(String token, String id, int to) => bool applyQueueMove(String token, String id, int to) {
members.containsKey(token) && queue.move(id, 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 /// [why] set: skipped because it cannot play (`queue-skip`, with the
/// reason), else removed by hand (`queue-remove`). /// reason), else removed by hand (`queue-remove`).
@@ -391,6 +395,7 @@ extension EngineQueueOps on SyncEngine {
target: gone.title, target: gone.title,
reason: why, reason: why,
at: now)); at: now));
whatsNext();
return true; return true;
} }
@@ -399,17 +404,20 @@ extension EngineQueueOps on SyncEngine {
if (!members.containsKey(token)) return false; if (!members.containsKey(token)) return false;
final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id; final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id;
if (target == null || queue.play == target) return false; 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 /// `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) { bool applyQueueCancel(String token) {
final member = members[token]; final member = members[token];
if (member == null) return false; if (member == null) return false;
final dropped = queue.dropAuto(); final dropped = queue.dropAuto();
final hadAdvance = advance != null; final hadAdvance = advance != null;
advance = null; cancelAdvance();
if (dropped == null && !hadAdvance) return false; if (dropped == null && !hadAdvance) return false;
feed.add(FeedMessage( feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now)); actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
+116 -5
View File
@@ -7,6 +7,7 @@ import 'constants.dart';
import 'presence.dart'; import 'presence.dart';
import 'protocol.dart'; import 'protocol.dart';
import 'queue.dart'; import 'queue.dart';
import 'whats_next.dart';
typedef SyncClock = DateTime Function(); typedef SyncClock = DateTime Function();
@@ -245,9 +246,19 @@ class SyncEngine {
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue` /// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
/// messages, taken from by swaps that name an item. /// messages, taken from by swaps that name an item.
final EngineQueue queue = EngineQueue(); final EngineQueue queue = EngineQueue();
/// The shared countdown to Up next (`state.advance`); reserved: only /// The shared countdown to Up next's first item at the end of the media
/// `cancel` touches it today. /// (`state.advance`, see [whatsNext]).
QueueAdvance? advance; 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 /// A member went `gone` (its socket closed or fell silent): the server
/// closes the socket and marks a relay it serves. /// closes the socket and marks a relay it serves.
void Function(EngineMember member)? onGone; void Function(EngineMember member)? onGone;
@@ -316,12 +327,97 @@ class SyncEngine {
at: now); at: now);
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace, /// 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() { void tick() {
sweepSilent(); sweepSilent();
sweepPresence(); sweepPresence();
queue.tick(now); queue.tick(now);
evaluate(); 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<MemberView> 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 /// Joining: loads in the background until its reports hold the media
@@ -617,6 +713,8 @@ class SyncEngine {
_seekSeq = seq; _seekSeq = seq;
_seekEventId = eventId; _seekEventId = eventId;
feed.add(_line('seek', actor: member)); 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. // Seeking back from the end: held, and resumes once the gate passes.
if (transport == Transport.finished) { if (transport == Transport.finished) {
transport = Transport.held; transport = Transport.held;
@@ -627,11 +725,15 @@ class SyncEngine {
/// Returns false (no state change) for media with non-finite numbers. /// Returns false (no state change) for media with non-finite numbers.
/// [queueId]: the Up next item this swap puts up; it leaves the queue /// [queueId]: the Up next item this swap puts up; it leaves the queue
/// (any other swap drops the continuation item, [EngineQueue.auto]). /// (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; if (!next.duration.isFinite || next.size < 0) return false;
final actor = members[actorToken]; final actor = members[actorToken];
queue.swapped(queueId); queue.swapped(queueId);
advance = null; advance = null;
_advancedId = null;
mediaId++; mediaId++;
media = next; media = next;
swapBy = actorToken; swapBy = actorToken;
@@ -648,7 +750,15 @@ class SyncEngine {
member.inSet = true; member.inSet = true;
member.buffered = 0; 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; return true;
} }
@@ -715,6 +825,7 @@ class SyncEngine {
eventId++; eventId++;
transport = Transport.finished; transport = Transport.finished;
feed.add(_line('finished')); feed.add(_line('finished'));
whatsNext();
return; return;
} }
final blocked = final blocked =
+63 -65
View File
@@ -1,9 +1,9 @@
import 'dart:math' as math; import 'dart:math' as math;
// What one viewer actually watched of one opened item, and the rule that // 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 // turns it into "watched", "barely looked" or "stopped part way" (D3).
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md // Pure, so every tracker (the device history, Jellyfin, AniList later)
// "Watched rule". // applies the same rule. See ARCHITECTURE.md "Watch tracking".
/// What a [WatchSession] amounts to when it ends. /// What a [WatchSession] amounts to when it ends.
enum WatchOutcome { enum WatchOutcome {
@@ -14,7 +14,8 @@ enum WatchOutcome {
/// whatever happened, with no resume point. /// whatever happened, with no resume point.
keepPlayed, 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, peek,
/// Part way: resume from [WatchVerdict.resumeAt]. /// Part way: resume from [WatchVerdict.resumeAt].
@@ -28,108 +29,105 @@ class WatchVerdict {
final double? resumeAt; final double? resumeAt;
const WatchVerdict(this.outcome, {this.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 @override
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})'; String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
} }
/// One opening of one item on this client. Fed the player position about /// One opening of one item on this app. Fed about once a second
/// once a second ([sample]); only normal playback counts as watched: the /// ([sample]) with the player's position, whether the media plays here
/// stretch between two samples while playing, when the position moved /// (the server's transport is playing and this player plays it) and
/// forward no faster than [maxRate] (plus [slack]) and the samples are at /// whether this viewer is In (present: `in`, from the presence the server
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches, /// reports for it). The basis is [timeIn]: wall time the media played
/// so rewatching a part does not count twice; [watched] is the plain sum. /// 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 { class WatchSession {
/// Played before this opening (Jellyfin `UserData.Played`). /// Played before this opening (Jellyfin `UserData.Played`, the device's
final bool playedBefore; /// watch history); set once known (Jellyfin answers after the open).
bool playedBefore;
/// The resume point before this opening, seconds (0 = none). /// The resume point before this opening, seconds (0 = none).
final double resumeBefore; double resumeBefore;
final double maxRate;
final double slack;
final double maxGap; final double maxGap;
WatchSession( WatchSession({this.playedBefore = false, this.resumeBefore = 0, this.maxGap = 10});
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
DateTime? startedAt; DateTime? startedAt;
DateTime? endedAt; DateTime? endedAt;
double? firstPosition; double? firstPosition;
double? lastPosition; 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; double furthest = 0;
/// Seconds of normal playback, rewatches included. /// Seconds the media played while this viewer was In.
double watched = 0; double timeIn = 0;
final _spans = <(double, double)>[]; /// The lowest position it played from while In; null before any.
({double pos, bool playing, DateTime at})? _prev; double? playedFrom;
/// Seconds of the item covered by normal playback (rewatches once). /// [timeIn] plus the time before the resume point when this opening
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1); /// continued from it.
double get credited {
/// The watched stretches, sorted and merged. final from = playedFrom;
List<(double, double)> get spans => List.unmodifiable(_spans); final resumed = resumeBefore > 0 && from != null && (from - resumeBefore).abs() <= 60;
return timeIn + (resumed ? resumeBefore : 0);
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);
} }
void _add(double a, double b) { ({DateTime at, double pos, bool counting})? _prev;
final out = <(double, double)>[];
var (lo, hi) = (a, b); void sample(double position, {required bool playing, required bool present, required DateTime at}) {
for (final (s, e) in _spans) { final prev = _prev;
if (e < lo || s > hi) { _prev = (at: at, pos: position, counting: playing && present);
out.add((s, e)); startedAt ??= at;
} else { endedAt = at;
lo = math.min(lo, s); if (present) {
hi = math.max(hi, e); firstPosition ??= position;
} lastPosition = position;
furthest = math.max(furthest, position);
} }
out if (prev == null || !prev.counting) return;
..add((lo, hi)) final dt = at.difference(prev.at).inMilliseconds / 1000;
..sort((x, y) => x.$1.compareTo(y.$1)); if (dt <= 0 || dt > maxGap) return;
_spans timeIn += dt;
..clear() final from = playedFrom;
..addAll(out); playedFrom = from == null ? prev.pos : math.min(from, prev.pos);
} }
/// The rule, for an item [runtime] seconds long whose ending starts at /// The rule, for an item [runtime] seconds long whose ending starts at
/// [endingStart] (an Outro segment or ED chapter; null = the real end): /// [endingStart] (an Outro segment or ED chapter; null = the real end):
/// - played before: [WatchOutcome.keepPlayed]; /// - played before: [WatchOutcome.keepPlayed];
/// - [covered] >= 70 % of the runtime, or the ending was reached /// - [credited] time In >= 70 % of the runtime, or the ending was
/// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The /// reached ([furthest]) with it >= 40 %: [WatchOutcome.played]. The
/// ending is the earlier of [endingStart] and the last 15 % (at most /// 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; /// 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 /// 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]; /// item): [WatchOutcome.peek];
/// - else [WatchOutcome.partial] at [lastPosition]. /// - else [WatchOutcome.partial] at [lastPosition].
/// An unknown runtime (0) never makes it played. /// An unknown runtime (0) never makes it played.
WatchVerdict verdict({required double runtime, double? endingStart}) { WatchVerdict verdict({required double runtime, double? endingStart}) {
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed); if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
final c = covered; final t = credited;
if (runtime > 0) { if (runtime > 0) {
final tail = runtime - math.min(240.0, 0.15 * runtime); final tail = runtime - math.min(240.0, 0.15 * runtime);
final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail; 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); return const WatchVerdict(WatchOutcome.played);
} }
} }
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0; 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); return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
} }
} }
+79
View File
@@ -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<MemberView> 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<MemberView> 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<MemberView> 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();
}
+1 -1
View File
@@ -316,7 +316,7 @@ void main() {
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse); 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 at = DateTime.utc(2026);
final s = StateBroadcast( final s = StateBroadcast(
eventId: 1, eventId: 1,
+129
View File
@@ -70,6 +70,7 @@ void main() {
}); });
test('skip asks for the first item; a play request times out', () { 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'); expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
engine.applyQueueAdd('a', link('one')); engine.applyQueueAdd('a', link('one'));
expect(engine.applyQueuePlay('a', ''), isTrue); expect(engine.applyQueuePlay('a', ''), isTrue);
@@ -171,4 +172,132 @@ void main() {
expect(engine.media!.name, 'Ep02'); 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);
});
});
} }
+16
View File
@@ -388,6 +388,22 @@ void main() {
group('swap', () { group('swap', () {
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0); 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', () { test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
join('a', 'alice'); join('a', 'alice');
engine.applyReady('a', true); engine.applyReady('a', true);
+127 -78
View File
@@ -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:sync_relay_core/core.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
/// Plays [from]..[to] at normal speed, one sample a second, starting at final _t0 = DateTime(2026, 1, 1);
/// wall second [t]; returns the wall second after.
int play(WatchSession w, int t, double from, double to) { /// One sample a second for [secs] seconds from wall second [t], the
var pos = from; /// position moving with the wall clock from [from] while [playing];
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); /// returns the wall second after.
while (pos < to) { int run(WatchSession w, int t, double from, int secs, {bool playing = true, bool present = true}) {
t++; for (var i = 0; i <= secs; i++) {
pos = pos + 1 > to ? to : pos + 1; w.sample(from + (playing ? i : 0), playing: playing, present: present, at: _t0.add(Duration(seconds: t + i)));
w.sample(pos, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t)));
} }
return t + 1; return t + secs + 1;
} }
void main() { void main() {
const ep = 1440.0; // 24 min const ep = 1440.0; // 24 min
const ed = 1350.0; // the ending starts 1.5 min before the end const ed = 1350.0; // the ending starts 1.5 min before the end
test('normal playback counts; seeks, skips and pauses do not', () { group('time In', () {
final w = WatchSession(); test('counts the time it played while In; pauses, holds and jumps add nothing', () {
var t = play(w, 0, 0, 60); final w = WatchSession();
// A jump ahead between two samples: not watched. var t = run(w, 0, 0, 60);
w.sample(600, playing: true, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); // Held (the server waits for someone): the clock runs, nothing counts.
t = play(w, t + 1, 600, 630); t = run(w, t, 60, 30, playing: false);
// Paused: the position does not move, nothing counts. // A jump ahead: only the time actually played after it.
w.sample(630, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t + 30))); t = run(w, t, 600, 30);
expect(w.covered, closeTo(90, 1)); expect(w.timeIn, closeTo(90, 2));
expect(w.spans, hasLength(2)); expect(w.furthest, 630);
expect(w.firstPosition, 0); expect(w.firstPosition, 0);
expect(w.lastPosition, 630); });
expect(w.furthest, 630);
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', () { group('verdict (D3)', () {
final w = WatchSession(); test('played: 70 % of the runtime In, or the ending (or the last 15 %) reached after 40 %', () {
var t = play(w, 0, 0, 300); final full = WatchSession();
t = play(w, t, 100, 200); // went back for a moment run(full, 0, 0, 1200); // 83 %
expect(w.covered, closeTo(300, 1)); expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
expect(w.watched, closeTo(400, 1));
});
test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () { // The opening and the ending skipped, stopped at the ED: Jellyfin's
final full = WatchSession(); // own rule (past 90 %) would count it too.
play(full, 0, 0, 1200); // 83 % final skipped = WatchSession();
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); 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 half = WatchSession();
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED t = run(half, 0, 0, 800); // 56 %, then a skip to the ED
play(skipped, t, ed, ed + 5); run(half, t, ed, 5);
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); expect(half.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);
// Watched with the opening skipped, stopped 3 min before the end. test('skipping around or a screenshot visit never counts', () {
final early = WatchSession(); // Through the whole episode in 30 s jumps, 2 s at each stop.
final u = play(early, 0, 0, 90); final hops = WatchSession();
play(early, u, 180, ep - 180); var t = 0;
expect(early.verdict(runtime: ep).outcome, WatchOutcome.played); 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(); final shot = WatchSession();
play(jumped, 0, 0, 400); // 28 %, then jumped to the end run(shot, 0, 700, 20);
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2)); expect(shot.verdict(runtime: ep).outcome, WatchOutcome.peek);
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
test('played before: stays played however little was watched', () { // A quarter watched, then a jump to the end: part way, not played.
final w = WatchSession(playedBefore: true); final jumped = WatchSession();
play(w, 0, 700, 730); // checking a moment t = run(jumped, 0, 0, 400);
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed); 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', () { test('played before: stays played however little was watched', () {
final peek = WatchSession(resumeBefore: 300); final w = WatchSession(playedBefore: true);
play(peek, 0, 900, 990); // 90 s run(w, 0, 700, 30);
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek); expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
expect(w.verdict(runtime: ep).watched, isTrue);
});
final movie = WatchSession(); test('a resume continues the earlier watch', () {
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 % final resumed = WatchSession(resumeBefore: 600);
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek); 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(); // Played from the start again: the earlier part is not counted twice.
final t = play(part, 0, 0, 500); final again = WatchSession(resumeBefore: 600);
part.sample(450, playing: false, at: DateTime(2026, 1, 1).add(Duration(seconds: t))); run(again, 0, 0, 300);
final v = part.verdict(runtime: ep); expect(again.credited, closeTo(300, 2));
expect(v.outcome, WatchOutcome.partial); });
expect(v.resumeAt, 450);
// A 3 min clip: half of it is not a peek. test('a peek leaves nothing; part way resumes at the last position', () {
final clip = WatchSession(); final peek = WatchSession(resumeBefore: 300);
play(clip, 0, 0, 90); run(peek, 0, 900, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial); expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
});
test('an unknown runtime never makes it played', () { final movie = WatchSession();
final w = WatchSession(); run(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
play(w, 0, 0, 1000); expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
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);
});
}); });
} }
+82
View File
@@ -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<String, bool> 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);
});
}
+23 -5
View File
@@ -50,7 +50,17 @@ class ServerHub {
engine = engine ?? SyncEngine() { engine = engine ?? SyncEngine() {
this.engine this.engine
..onGone = _memberGone ..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() { static String _randomId() {
@@ -698,8 +708,16 @@ void mountSyncRoute(
final swap = SwapIntent.fromJson(decoded); final swap = SwapIntent.fromJson(decoded);
var parsed = swap.media; var parsed = swap.media;
final queueId = swap.queueId; 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)}' _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) { if (queueId != null) {
// Already put up by someone else: nothing to do. // Already put up by someone else: nothing to do.
if (!hub.engine.queue.has(queueId)) return; if (!hub.engine.queue.has(queueId)) return;
@@ -737,7 +755,7 @@ void mountSyncRoute(
: promoted != null : promoted != null
? hub.promotedRelay(promoted, queueId!) ? hub.promotedRelay(promoted, queueId!)
: hub.relayFor(token, const MediaRelay())); : 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(); promoted?.dispose();
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return; return;
@@ -785,7 +803,7 @@ void mountSyncRoute(
duration: parsed.duration, duration: parsed.duration,
ref: parsed.ref, ref: parsed.ref,
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq)); 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(); promoted?.dispose();
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap'); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return; return;
@@ -827,7 +845,7 @@ void mountSyncRoute(
wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap'); wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap');
return; 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'); wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return; return;
} }
+106
View File
@@ -141,4 +141,110 @@ void main() {
expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again'); 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)); 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<QueueItem> 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);
});
});
} }