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