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
+2668 -908

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