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

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

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

No files matched your search

+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,13 +153,22 @@ class ShowMatcher {
// ------------------------------------------------------------- reads
/// Remembers [library]'s files, so [refForPath] knows their show.
/// Remembers [library]'s files, so [refForPath] and [showAt] know
/// their show.
void remember(LocalLibrary library) {
for (final e in library.files) {
_byPath[e.path] = e;
}
for (final s in library.shows) {
for (final e in s.files) {
_showByPath[e.path] = s;
}
}
}
/// The show the file at [path] belongs to, as seen in a scan this run.
LocalShow? showAt(String path) => _showByPath[path];
AniListEntry? entry(int id) {
final known = _entries[id];
if (known != null) return known;
+199 -123
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};
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
final ids = {for (final q in r.queue) q.id};
_skipped.removeWhere((id) => !ids.contains(id));
// Kept while a restarted server waits for what played: Up next comes
// back with it.
if (_restoreFrom == -1) {
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
}
}
/// [item] should go up now: by this app when it is the one that can;
/// nobody can: the lowest member takes it off with the reason.
void _bringUp(QueueItem item, StateBroadcast r) {
final perf = queuePerformer(item, r.members);
if (perf.id == null) {
final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.connection.sendQueue(QueueIntent.remove(item.id, why: why));
}
return;
}
if (perf.id == _me) _perform(item);
}
/// The media finished with Up next waiting: the card, and with
/// auto-advance on the countdown, for its first item.
void _atEnd(StateBroadcast r) {
// Up next goes before the next episode, even one already counting.
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
final head = r.queue.first;
final perf = queuePerformer(head, r.members);
if (perf.id == null && perf.why != null) {
// Taken off by [_bringUp]'s rule; the next state shows the next one.
_bringUp(head, r);
return;
}
final c = countdown.peek();
if (c != null && c.mediaId == r.mediaId) {
if (c.item.id != head.id || c.item.thumb != head.thumb) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
}
return;
}
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
_timer?.cancel();
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
}
void _tick() {
final c = countdown.peek();
final r = coord.serverState.peek();
final left = c?.secondsLeft;
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
_stopCountdown();
return;
}
if (left > 1) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
return;
}
_timer?.cancel();
_timer = null;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
// Decided on the latest state: only the app that can puts it up.
/// The end card from the server's state: the media ended with something
/// next; the seconds of `state.advance` move on with a 1 s timer.
void _syncCountdown(StateBroadcast r) {
final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
if (r.transport != 'finished' || head == null || r.media == null) {
_timer?.cancel();
_timer = null;
if (countdown.peek() != null) countdown.value = null;
return;
}
final a = r.advance != null && r.advance!.id == head.id ? r.advance : null;
int? left;
String? waiting;
if (a != null) {
left = advanceSecondsLeft(a, coord.connection.serverNow());
if (left == 0 && queuePerformer(head, r.members).id == null) {
waiting = r.members.where((m) => m.id == head.by).firstOrNull?.name ?? head.byName;
}
}
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: left, waitingFor: waiting);
if (a == null) {
_timer?.cancel();
_timer = null;
} else {
_timer ??= Timer.periodic(const Duration(seconds: 1), (_) {
final now = coord.serverState.peek();
if (now != null) _syncCountdown(now);
});
}
}
void _stopCountdown() {
_timer?.cancel();
_timer = null;
countdown.value = null;
// ------------------------------------------------------------ restart
/// The server restarted without media; what played ([mediaId]) is being
/// offered back (-1: that is over).
void restoreFrom(int mediaId) => _restoreFrom = mediaId;
/// What played is back after a server restart: Up next as [before] had
/// it comes back too. The app whose offer won ([winner]) adds the items
/// anyone can put up (links everyone opens, Jellyfin items); every app
/// its own links passed on and files (only it has them; [self] is its
/// member id then). A server-fetched link's address stayed with the old
/// server, so it is gone; the continuation comes back as usual.
void restoreAfterRestart(StateBroadcast before, String self, {required bool winner}) {
for (final q in before.queue) {
if (q.auto) continue;
final ok = switch (q.how) {
QueueHow.direct || QueueHow.jellyfin => winner,
QueueHow.through || QueueHow.file => self.isNotEmpty && q.by == self && _local.containsKey(q.key),
QueueHow.server => false,
};
if (!ok) continue;
final local = _local[q.key];
if (local != null) _local[q.key] = (pick: local.pick, at: DateTime.now());
final thumb = thumbs.bytesFor(q.thumb);
coord.connection.sendQueue(QueueIntent.add(
QueueItem(media: q.media, how: q.how, title: q.title, sub: q.sub, duration: q.duration, key: q.key),
thumb: thumb == null ? '' : base64Encode(thumb)));
}
_restoreFrom = -1;
}
// ------------------------------------------------------- continuation
/// Adds what continues the media playing (`auto`, PROTOCOL.md "Queue")
/// when this app may ([mayAddContinuation]: it put it up, or that
/// member is not in and this is the first who is), knows what comes
/// next, and it is not there or cancelled yet; with the setting on. A
/// Jellyfin episode played with this app's account continues with the
/// series' next one, a file this app put up with the next episode of its
/// show in the library (numbers skipped on the way said in its line).
void _continue(StateBroadcast? r) {
if (r == null || r.media == null || !coord.settings.autoAdvance.peek()) return;
final id = r.mediaId;
if (_continuedFor == id || _hiddenFor.peek() == id) return;
if (r.queue.any((q) => q.auto && q.forMedia == id)) {
_continuedFor = id;
return;
}
final swapBy = r.media?['swapBy'];
if (!mayAddContinuation(_me, swapBy is String ? swapBy : null, r.members)) return;
final ref = refOfStateMedia(r.media);
final next = coord.nextEpisode.peek();
if (ref != null) {
if (next == null || !sameJellyfinItem(ref, coord.currentRef.peek())) return;
_continuedFor = id;
unawaited(_addContinuation(id, next.pick));
return;
}
final own = coord.ownFile.peek();
final finder = coord.nextFile;
if (own == null || own.$1 != id || finder == null) return;
_continuedFor = id;
unawaited(() async {
try {
final n = await finder(own.$2);
if (n == null) return;
final gap = n.skipped;
await _addContinuation(id, FileMedia(n.episode.path),
note: gap == null ? null : 'skips ${gap.label}');
} catch (e) {
debugPrint('queue: what continues ${own.$2}: $e');
}
}());
}
Future<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",
+127 -81
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,58 +325,33 @@ class UpNextPreview extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(_pad),
child: SignalBuilder(builder: (context) {
final item = queue.items.value.firstOrNull;
final episode = queue.shownContinuation.value.firstOrNull;
final queued = queue.items.value.firstOrNull;
final item = queued ?? queue.continues.value;
final warm = queue.warm.value;
const picture = _width - 2 * _pad;
if (item != null) {
final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
const SizedBox(height: 10),
Text(more > 0 ? 'UP NEXT · $more MORE AFTER IT' : 'UP NEXT', style: _kicker),
const SizedBox(height: 3),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (item.sub.isNotEmpty)
Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
const SizedBox(height: 6),
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
],
);
}
if (episode == null) return const SizedBox.shrink();
final n = episodeNumbers(episode.item);
final runtime = episode.pick.runtime;
final sub = [episode.item.seriesName ?? '', if (runtime != null) queueClock(runtime.inSeconds.toDouble())]
.where((s) => s.isNotEmpty)
.join(' · ');
final overview = episode.item.overview?.trim() ?? '';
final ready = warm?.id == 'next:${episode.item.id}' ? warm!.state : null;
if (item == null) return const SizedBox.shrink();
final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId;
final more = queue.items.value.length - 1;
final ready = warm?.id == item.id ? warm!.state : null;
final kicker = queued == null
? 'CONTINUES WITH'
: more > 0
? 'UP NEXT · $more MORE AFTER IT'
: 'UP NEXT';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_EpisodeThumb(queue: queue, episode: episode, width: picture, radius: 8),
QueueThumb(queue: queue, item: item, width: picture, radius: 8),
const SizedBox(height: 10),
Text(n == null ? 'NEXT EPISODE' : 'NEXT EPISODE · $n', style: _kicker),
Text(kicker, style: _kicker),
const SizedBox(height: 3),
Text(episode.item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (sub.isNotEmpty) Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
if (overview.isNotEmpty) ...[
const SizedBox(height: 6),
Text(overview,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white54, fontSize: 12, height: 1.35)),
],
_footer(ready, null),
Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: _title),
if (item.sub.isNotEmpty) Text(item.sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: _sub),
const SizedBox(height: 6),
_MetaLine(item: item, members: r?.members ?? const [], selfId: selfId, wrap: true),
_footer(ready, ready == null ? performerNote(queue, item, r) : null),
],
);
}),
@@ -481,8 +444,7 @@ class _EpisodeThumb extends StatelessWidget {
final QueueController queue;
final EpisodeLink episode;
final double width;
final double radius;
const _EpisodeThumb({required this.queue, required this.episode, required this.width, this.radius = 6});
const _EpisodeThumb({required this.queue, required this.episode, required this.width});
@override
Widget build(BuildContext context) {
@@ -490,7 +452,7 @@ class _EpisodeThumb extends StatelessWidget {
final client = queue.coord.jellyfin.clientFor(e.pick.serverId);
final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb);
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: width,
height: width * 9 / 16,
@@ -838,6 +800,7 @@ class _QueuePanelState extends State<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,15 +931,17 @@ class _QueuePanelState extends State<QueuePanel> {
const Text('CONTINUES WITH',
style: TextStyle(color: Colors.white38, fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600)),
const Spacer(),
TextButton(
key: const ValueKey('queue-clear-continuation'),
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
onPressed: _q.clearContinuation,
child: const Text('Clear', style: TextStyle(fontSize: 12)),
),
if (auto != null)
TextButton(
key: const ValueKey('queue-clear-continuation'),
style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact),
onPressed: _q.clearContinuation,
child: const Text('Clear', style: TextStyle(fontSize: 12)),
),
],
),
),
if (auto != null) _ContinuesRow(item: auto, queue: _q, warm: warm),
for (final e in cont) _ContinuationRow(episode: e, queue: _q),
];
@@ -1479,6 +1448,76 @@ class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin
}
}
/// The continuation's row (the `auto` item): greyed like what is not
/// queued yet, its picture with how ready it is here, ▶ plays it now.
class _ContinuesRow extends StatefulWidget {
final QueueItem item;
final QueueController queue;
final WarmState? warm;
const _ContinuesRow({required this.item, required this.queue, required this.warm});
@override
State<_ContinuesRow> createState() => _ContinuesRowState();
}
class _ContinuesRowState extends State<_ContinuesRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hover ? 0.95 : 0.7,
child: Container(
key: ValueKey('queue-continues-${item.id}'),
height: queueRowHeight,
padding: const EdgeInsets.only(left: _lead, right: 8),
decoration: BoxDecoration(
color: _hover ? AppMenuStyle.hover : Colors.transparent,
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
),
child: Row(
children: [
Stack(
children: [
QueueThumb(queue: widget.queue, item: item, width: _thumb),
if (widget.warm != null) Positioned(top: 3, right: 3, child: _WarmDot(state: widget.warm!)),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: _titleStyle),
Text(
[item.sub, queueClock(item.duration)].where((s) => s.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _subStyle,
),
],
),
),
_RoundIcon(
key: ValueKey('queue-play-${item.id}'),
icon: Icons.play_arrow_rounded,
tooltip: 'Play now',
onPressed: () => widget.queue.playNow(item.id),
),
],
),
),
),
);
}
}
class _ContinuationRow extends StatefulWidget {
final EpisodeLink episode;
final QueueController queue;
@@ -1545,8 +1584,9 @@ class _ContinuationRowState extends State<_ContinuationRow> {
// -------------------------------------------------------------- end card
/// When the media ended with Up next waiting: its picture, title and who
/// added it, the countdown with Cancel, and Play now.
/// When the media ended with something next (Up next's first item, else
/// the continuation): its picture, title and who added it, the server's
/// countdown with Cancel (for everyone), and Play now.
class UpNextCard extends StatelessWidget {
final QueueController queue;
final UpNextCountdown countdown;
@@ -1559,7 +1599,13 @@ class UpNextCard extends StatelessWidget {
final left = c.secondsLeft;
final r = queue.coord.serverState.value;
final selfId = queue.coord.connection.memberId;
final waiting = left == null ? performerNote(queue, item, r) : null;
final waitingFor = c.waitingFor;
final waiting = waitingFor != null
? 'Waiting for $waitingFor'
: left == null || left == 0
? performerNote(queue, item, r)
: null;
final what = item.auto ? 'Next' : 'Up next';
return AppGlass(
key: const ValueKey('up-next-card'),
child: SizedBox(
@@ -1579,7 +1625,7 @@ class UpNextCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(left == null ? 'Up next' : 'Up next in $left s',
Text(left == null || left == 0 ? what : '$what in $left s',
style: const TextStyle(color: Colors.white60, fontSize: 12)),
const SizedBox(height: 2),
Text(item.title,
@@ -1605,7 +1651,7 @@ class UpNextCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: TweenAnimationBuilder<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();
}