Restructure step 6: what's next and watch tracking
- 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:
1 parent
a87ee98faa
commit
741b258ac1
55 files changed
+2724
-964
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user