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:
foxyzandClaude Opus 5.5 committed 2026-10-16 02:06:04 +03:00
1 parent a87ee98faa
commit 741b258ac1
55 files changed
+2724 -964

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ export 'src/media_source.dart';
export 'src/sync_engine.dart';
export 'src/updater.dart';
export 'src/watch_session.dart';
export 'src/whats_next.dart';
export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart';
+3 -1
View File
@@ -92,10 +92,12 @@ abstract class RelayConstants {
/// Up next (`queue`): at most this many items; a title or second line
/// is cut to [maxQueueTextLength] characters; an item's picture is
/// capped like a poster ([maxPosterBytes]). A play request nobody acts on
/// ends after [queuePlayTimeoutSecs].
/// ends after [queuePlayTimeoutSecs]. The countdown to the first item at
/// the end of the media (`state.advance`) runs [advanceSecs].
static const int maxQueueItems = 100;
static const int maxQueueTextLength = 200;
static const int queuePlayTimeoutSecs = 30;
static const int advanceSecs = 10;
/// Diagnostics sharing (`/l/...`, ARCHITECTURE "Logging"): a member's
/// batch carries at most [diagBatchBytes] of lines (gzip on the wire, at
+37 -10
View File
@@ -836,16 +836,42 @@ class SeekIntent {
}
}
/// `swap.restore`: what played before the server restarted, offered back
/// by an app that still holds it: at [position] (seconds), [transport]
/// as it was (`playing`, `held` or `finished`). The server takes it only
/// while it has no media (the first offer wins, later ones are ignored).
class SwapRestore {
final double position;
final String transport;
const SwapRestore({required this.position, this.transport = 'held'});
Map<String, Object?> toJson() => {'position': position, 'transport': transport};
static SwapRestore? fromJson(Object? j) {
if (j is! Map) return null;
final t = j['transport'];
return SwapRestore(
position: finiteDouble(j['position'], 'restore.position'),
transport: t == 'playing' || t == 'finished' ? t as String : 'held');
}
}
class SwapIntent {
final HelloMedia media;
/// The Up next item this swap puts up (`queue[].id`); the server takes
/// it out of the queue with the swap, and ignores the swap when it is no
/// longer queued (someone else put it up first).
final String? queueId;
const SwapIntent({required this.media, this.queueId});
/// Puts back what played before a server restart ([SwapRestore]).
final SwapRestore? restore;
const SwapIntent({required this.media, this.queueId, this.restore});
Map<String, Object?> toJson() =>
{'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId};
Map<String, Object?> toJson() => {
't': 'swap',
'media': media.toJson(),
if (queueId != null) 'queueId': queueId,
if (restore != null) 'restore': restore!.toJson(),
};
factory SwapIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'swap') throw const ProtocolException('not a swap intent');
@@ -854,7 +880,8 @@ class SwapIntent {
final queueId = json['queueId'];
return SwapIntent(
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
queueId: queueId is String && queueId.isNotEmpty ? queueId : null);
queueId: queueId is String && queueId.isNotEmpty ? queueId : null,
restore: SwapRestore.fromJson(json['restore']));
}
}
@@ -1147,11 +1174,11 @@ class HoldInfo {
}
}
/// `state.advance` (reserved, step 6): the shared countdown to Up next's
/// first item when the media finishes. At [at] + [secs] (server clock) the
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears
/// it for everyone. The server parses and broadcasts it but never starts
/// one yet.
/// `state.advance`: the shared countdown to Up next's first item when the
/// media finishes. At [at] + [secs] (server clock) the server sets
/// `queuePlay` to [id] (holding while its performer or its relay's source
/// is `gone`); `{"t":"queue","op":"cancel"}` and a seek clear it for
/// everyone (see `SyncEngine.whatsNext`).
class QueueAdvance {
final String id;
final DateTime at;
@@ -1211,7 +1238,7 @@ class StateBroadcast {
final String? queuePlay;
/// Why it is held ([HoldInfo]); null unless `held` with media up.
final HoldInfo? hold;
/// The shared countdown to Up next (reserved, [QueueAdvance]).
/// The shared countdown to Up next's first item ([QueueAdvance]); null when none runs.
final QueueAdvance? advance;
const StateBroadcast({
required this.eventId,
+25 -17
View File
@@ -2,9 +2,9 @@ import 'dart:typed_data';
import 'constants.dart';
import 'errors.dart';
import 'presence.dart';
import 'protocol.dart';
import 'sync_engine.dart';
import 'whats_next.dart';
// Up next: what plays after the current media, held by the server for
// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the
@@ -342,14 +342,15 @@ typedef QueueRefusal = ({ErrCode code, String message});
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
/// `advance-cancel`). Each returns whether the queue changed;
/// [applyQueueAdd] a refusal for the sender instead (null when added).
/// `advance-cancel`), then what's next ([SyncEngine.whatsNext]). Each
/// returns whether the queue changed; [applyQueueAdd] a refusal for the
/// sender instead (null when added).
extension EngineQueueOps on SyncEngine {
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An
/// [QueueItem.auto] item must continue the current media
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the
/// first member who is.
/// it up or, when that member is not `in`, from the first member who is
/// ([mayAddContinuation]); it adds no feed line (it is no news).
QueueRefusal? applyQueueAdd(String token, QueueItem item,
{bool next = false, String? privateUrl, Uint8List? thumb}) {
final member = members[token];
@@ -358,24 +359,27 @@ extension EngineQueueOps on SyncEngine {
if (media == null || item.forMedia != mediaId) {
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
}
final swapper = members[swapBy];
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull;
final allowed = token == swapBy ||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
if (!allowed) {
final views = [for (final m in members.values) m.view(inGate: false)];
if (!mayAddContinuation(member.id, members[swapBy]?.id, views)) {
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
}
}
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
next: next, privateUrl: privateUrl, thumb: thumb);
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
if (!added.auto) {
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
}
whatsNext();
return null;
}
bool applyQueueMove(String token, String id, int to) =>
members.containsKey(token) && queue.move(id, to);
bool applyQueueMove(String token, String id, int to) {
if (!members.containsKey(token) || !queue.move(id, to)) return false;
whatsNext();
return true;
}
/// [why] set: skipped because it cannot play (`queue-skip`, with the
/// reason), else removed by hand (`queue-remove`).
@@ -391,6 +395,7 @@ extension EngineQueueOps on SyncEngine {
target: gone.title,
reason: why,
at: now));
whatsNext();
return true;
}
@@ -399,17 +404,20 @@ extension EngineQueueOps on SyncEngine {
if (!members.containsKey(token)) return false;
final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id;
if (target == null || queue.play == target) return false;
return queue.requestPlay(target, now);
if (!queue.requestPlay(target, now)) return false;
whatsNext();
return true;
}
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`).
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`);
/// no countdown starts again for this media.
bool applyQueueCancel(String token) {
final member = members[token];
if (member == null) return false;
final dropped = queue.dropAuto();
final hadAdvance = advance != null;
advance = null;
cancelAdvance();
if (dropped == null && !hadAdvance) return false;
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
+116 -5
View File
@@ -7,6 +7,7 @@ import 'constants.dart';
import 'presence.dart';
import 'protocol.dart';
import 'queue.dart';
import 'whats_next.dart';
typedef SyncClock = DateTime Function();
@@ -245,9 +246,19 @@ class SyncEngine {
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
/// messages, taken from by swaps that name an item.
final EngineQueue queue = EngineQueue();
/// The shared countdown to Up next (`state.advance`); reserved: only
/// `cancel` touches it today.
/// The shared countdown to Up next's first item at the end of the media
/// (`state.advance`, see [whatsNext]).
QueueAdvance? advance;
/// The relay of Up next item `queueId` waits for its source, who is
/// `gone` (set by the server: the engine does not hold relays). The
/// countdown holds while it does.
bool Function(String queueId)? itemSourceGone;
// The mediaId whose countdown was cancelled (`cancel`): it does not
// start again for that media.
int _advanceOffFor = -1;
// The item last asked to play for the current media: not counted down
// again.
String? _advancedId;
/// A member went `gone` (its socket closed or fell silent): the server
/// closes the socket and marks a relay it serves.
void Function(EngineMember member)? onGone;
@@ -316,12 +327,97 @@ class SyncEngine {
at: now);
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
/// a stale play request, end of media.
/// a stale play request, end of media, what's next.
void tick() {
sweepSilent();
sweepPresence();
queue.tick(now);
evaluate();
whatsNext();
}
/// What's next (PROTOCOL.md "Queue"), on every tick and queue change:
/// - a play request (`queuePlay`) nobody here can serve is skipped
/// (feed `queue-skip`, actor `server`, with why: [queuePerformer]);
/// - with nothing up, the first item is asked to play at once;
/// - at the end of the media with an item first, the countdown
/// ([advance], [RelayConstants.advanceSecs]) starts (not again for a
/// media whose countdown was cancelled, nor for the item it already
/// asked for); it follows the first item when that changes, keeping
/// its end; it ends with the end of `finished` (a seek), an empty
/// queue, `cancel` or a swap. At its end the first item is asked to
/// play (`queuePlay`) and the countdown is over, unless nobody can
/// put it up (skipped) or its performer is not around or its relay's
/// source is `gone` ([itemSourceGone]): then it holds until they are
/// back.
/// Returns whether anything changed.
bool whatsNext() {
var changed = false;
final views = [for (final m in members.values) m.view(inGate: false)];
final play = queue.play;
if (play != null) {
final item = queue.item(play);
final why = item == null ? null : queuePerformer(item, views).why;
if (item != null && why != null) {
_skipQueued(item, why);
changed = true;
}
}
final first = queue.items.firstOrNull;
if (media == null) {
if (first != null && queue.play == null) changed = _due(first, views) || changed;
return changed;
}
var a = advance;
if (a != null && (transport != Transport.finished || first == null || queue.play != null)) {
advance = a = null;
changed = true;
}
if (transport != Transport.finished || first == null || queue.play != null) return changed;
if (a == null) {
if (_advanceOffFor == mediaId || _advancedId == first.id) return changed;
advance = a = QueueAdvance(id: first.id, at: now, secs: RelayConstants.advanceSecs);
changed = true;
} else if (a.id != first.id) {
advance = a = QueueAdvance(id: first.id, at: a.at, secs: a.secs);
changed = true;
}
if (now.isBefore(a.at.add(Duration(seconds: a.secs)))) return changed;
return _due(first, views) || changed;
}
/// [item] should play now: asked for (`queuePlay`), skipped when nobody
/// can put it up, or held. Returns whether anything changed.
bool _due(QueueItem item, List<MemberView> views) {
final perf = queuePerformer(item, views);
final why = perf.why;
if (why != null) {
_skipQueued(item, why);
return true;
}
if (perf.id == null || (itemSourceGone?.call(item.id) ?? false)) return false;
queue.requestPlay(item.id, now);
advance = null;
_advancedId = item.id;
return true;
}
void _skipQueued(QueueItem item, String why) {
queue.remove(item.id);
// The countdown goes on with the next item (asked for on the next
// tick when its time is up).
final a = advance, first = queue.items.firstOrNull;
if (a != null && a.id == item.id) {
advance = first == null ? null : QueueAdvance(id: first.id, at: a.at, secs: a.secs);
}
feed.add(FeedMessage(
actor: 'server', actorId: 'server', action: 'queue-skip', target: item.title, reason: why, at: now));
}
/// `cancel` for the current media: no countdown for it any more.
void cancelAdvance() {
advance = null;
_advanceOffFor = mediaId;
}
/// Joining: loads in the background until its reports hold the media
@@ -617,6 +713,8 @@ class SyncEngine {
_seekSeq = seq;
_seekEventId = eventId;
feed.add(_line('seek', actor: member));
// A seek stops the countdown to Up next.
advance = null;
// Seeking back from the end: held, and resumes once the gate passes.
if (transport == Transport.finished) {
transport = Transport.held;
@@ -627,11 +725,15 @@ class SyncEngine {
/// Returns false (no state change) for media with non-finite numbers.
/// [queueId]: the Up next item this swap puts up; it leaves the queue
/// (any other swap drops the continuation item, [EngineQueue.auto]).
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
/// [restore]: what played before a restart, put back where it was (the
/// server takes it only while nothing is up; see `swap.restore`): held
/// at its position (the gate starts it again), or finished.
bool applySwap(String actorToken, HelloMedia next, {String? queueId, SwapRestore? restore}) {
if (!next.duration.isFinite || next.size < 0) return false;
final actor = members[actorToken];
queue.swapped(queueId);
advance = null;
_advancedId = null;
mediaId++;
media = next;
swapBy = actorToken;
@@ -648,7 +750,15 @@ class SyncEngine {
member.inSet = true;
member.buffered = 0;
}
feed.add(_line('swap', actor: actor));
if (restore != null) {
final d = next.duration;
position = restore.position < 0 ? 0 : (d > 0 && restore.position > d ? d : restore.position);
if (restore.transport == 'finished' && d > 0) {
position = d;
transport = Transport.finished;
}
}
feed.add(_line('swap', actor: actor, reason: restore == null ? '' : 'restore'));
return true;
}
@@ -715,6 +825,7 @@ class SyncEngine {
eventId++;
transport = Transport.finished;
feed.add(_line('finished'));
whatsNext();
return;
}
final blocked =
+63 -65
View File
@@ -1,9 +1,9 @@
import 'dart:math' as math;
// What one viewer actually watched of one opened item, and the rule that
// turns it into "watched", "barely looked" or "stopped part way". Pure, so
// any tracker (Jellyfin today) applies the same rule. See ARCHITECTURE.md
// "Watched rule".
// turns it into "watched", "barely looked" or "stopped part way" (D3).
// Pure, so every tracker (the device history, Jellyfin, AniList later)
// applies the same rule. See ARCHITECTURE.md "Watch tracking".
/// What a [WatchSession] amounts to when it ends.
enum WatchOutcome {
@@ -14,7 +14,8 @@ enum WatchOutcome {
/// whatever happened, with no resume point.
keepPlayed,
/// Barely watched (a peek, a screenshot): leave the item as it was.
/// Barely watched (a peek, a screenshot visit, skipping around): leave
/// the item as it was.
peek,
/// Part way: resume from [WatchVerdict.resumeAt].
@@ -28,108 +29,105 @@ class WatchVerdict {
final double? resumeAt;
const WatchVerdict(this.outcome, {this.resumeAt});
/// It counts as watched (played now, or played before).
bool get watched => outcome == WatchOutcome.played || outcome == WatchOutcome.keepPlayed;
@override
String toString() => 'WatchVerdict(${outcome.name}${resumeAt == null ? '' : ', $resumeAt'})';
}
/// One opening of one item on this client. Fed the player position about
/// once a second ([sample]); only normal playback counts as watched: the
/// stretch between two samples while playing, when the position moved
/// forward no faster than [maxRate] (plus [slack]) and the samples are at
/// most [maxGap] apart. Seeks, skips and pauses add nothing. [covered] is the union of the watched stretches,
/// so rewatching a part does not count twice; [watched] is the plain sum.
/// One opening of one item on this app. Fed about once a second
/// ([sample]) with the player's position, whether the media plays here
/// (the server's transport is playing and this player plays it) and
/// whether this viewer is In (present: `in`, from the presence the server
/// reports for it). The basis is [timeIn]: wall time the media played
/// while this viewer was In. Time sat out (Away), joining, gone, paused or
/// held never counts, nor does a jump: skipping around adds only the time
/// actually played. Two samples further apart than [maxGap] add nothing
/// (the app stalled). A resume carries the earlier watch: when what played
/// here starts at about the resume point ([resumeBefore], 60 s either
/// way) and nothing before it played, the time up to it counts as watched
/// before ([credited]).
class WatchSession {
/// Played before this opening (Jellyfin `UserData.Played`).
final bool playedBefore;
/// Played before this opening (Jellyfin `UserData.Played`, the device's
/// watch history); set once known (Jellyfin answers after the open).
bool playedBefore;
/// The resume point before this opening, seconds (0 = none).
final double resumeBefore;
double resumeBefore;
final double maxRate;
final double slack;
final double maxGap;
WatchSession(
{this.playedBefore = false, this.resumeBefore = 0, this.maxRate = 2, this.slack = 1.5, this.maxGap = 10});
WatchSession({this.playedBefore = false, this.resumeBefore = 0, this.maxGap = 10});
DateTime? startedAt;
DateTime? endedAt;
double? firstPosition;
double? lastPosition;
/// The furthest position seen, reached by playing or by a jump.
/// The furthest position seen while In, reached by playing or by a jump.
double furthest = 0;
/// Seconds of normal playback, rewatches included.
double watched = 0;
/// Seconds the media played while this viewer was In.
double timeIn = 0;
final _spans = <(double, double)>[];
({double pos, bool playing, DateTime at})? _prev;
/// The lowest position it played from while In; null before any.
double? playedFrom;
/// Seconds of the item covered by normal playback (rewatches once).
double get covered => _spans.fold(0.0, (s, e) => s + e.$2 - e.$1);
/// The watched stretches, sorted and merged.
List<(double, double)> get spans => List.unmodifiable(_spans);
void sample(double position, {required bool playing, required DateTime at}) {
final prev = _prev;
_prev = (pos: position, playing: playing, at: at);
startedAt ??= at;
endedAt = at;
firstPosition ??= position;
lastPosition = position;
furthest = math.max(furthest, position);
if (prev == null || !prev.playing) return;
final dt = at.difference(prev.at).inMilliseconds / 1000;
final dp = position - prev.pos;
if (dt <= 0 || dt > maxGap || dp <= 0 || dp > dt * maxRate + slack) return;
watched += dp;
_add(prev.pos, position);
/// [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);
}
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);
}
({DateTime at, double pos, bool counting})? _prev;
void sample(double position, {required bool playing, required bool present, required DateTime at}) {
final prev = _prev;
_prev = (at: at, pos: position, counting: playing && present);
startedAt ??= at;
endedAt = at;
if (present) {
firstPosition ??= position;
lastPosition = position;
furthest = math.max(furthest, position);
}
out
..add((lo, hi))
..sort((x, y) => x.$1.compareTo(y.$1));
_spans
..clear()
..addAll(out);
if (prev == null || !prev.counting) return;
final dt = at.difference(prev.at).inMilliseconds / 1000;
if (dt <= 0 || dt > maxGap) return;
timeIn += dt;
final from = playedFrom;
playedFrom = from == null ? prev.pos : math.min(from, prev.pos);
}
/// The rule, for an item [runtime] seconds long whose ending starts at
/// [endingStart] (an Outro segment or ED chapter; null = the real end):
/// - played before: [WatchOutcome.keepPlayed];
/// - [covered] >= 70 % of the runtime, or the ending was reached
/// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The
/// - [credited] time In >= 70 % of the runtime, or the ending was
/// reached ([furthest]) with it >= 40 %: [WatchOutcome.played]. The
/// ending is the earlier of [endingStart] and the last 15 % (at most
/// 4 min): someone who watched that far and stops is done with it;
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short
/// 4 min): someone who watched that far and stops is done with it.
/// Any watch Jellyfin's own rule counts (a position past 90 %, reached
/// by playing) counts here too, also with the opening and ending
/// skipped;
/// - [credited] under max(2 min, 5 %) (at most a quarter of a short
/// item): [WatchOutcome.peek];
/// - else [WatchOutcome.partial] at [lastPosition].
/// An unknown runtime (0) never makes it played.
WatchVerdict verdict({required double runtime, double? endingStart}) {
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
final c = covered;
final t = credited;
if (runtime > 0) {
final tail = runtime - math.min(240.0, 0.15 * runtime);
final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail;
if (c >= 0.7 * runtime || (furthest >= end && c >= 0.4 * runtime)) {
if (t >= 0.7 * runtime || (furthest >= end && t >= 0.4 * runtime)) {
return const WatchVerdict(WatchOutcome.played);
}
}
final peekBelow = runtime > 0 ? math.min(math.max(120.0, 0.05 * runtime), 0.25 * runtime) : 120.0;
if (c < peekBelow) return const WatchVerdict(WatchOutcome.peek);
if (t < peekBelow) return const WatchVerdict(WatchOutcome.peek);
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'presence.dart';
import 'protocol.dart';
import 'queue.dart';
// What's next, the rules every side reads the same way (PROTOCOL.md
// "Queue", ARCHITECTURE.md "What's next"): who puts an Up next item up.
// Pure and only on what `state` carries, so the server (which holds the
// countdown and skips what nobody can play) and every app agree without
// a wire message.
/// Who puts an Up next item up: [id] a member id; null with [why] when
/// nobody here can (the server skips the item), null without [why] when
/// nobody is around right now (it waits).
typedef QueuePerformer = ({String? id, String? why});
/// Connected and watching (`in` or `joining`): can do things for the
/// party now (put an Up next item up).
bool isPresentMember(MemberView m) => m.presence == Presence.watching || m.presence == Presence.joining;
/// - A link passed on by its adder, or the adder's file: only the adder
/// (their app has the file, the cookies). Gone (disconnected, in its
/// grace): it waits for them; left for good: skipped.
/// - Jellyfin: the adder when it plays that server with its own account,
/// else the lowest member id that does (`jf[serverId] == true`,
/// present). Nobody: skipped.
/// - A link everyone opens, or one the server fetches: the adder when
/// present, else the lowest present member id; nobody present: it waits.
QueuePerformer queuePerformer(QueueItem item, List<MemberView> members) {
MemberView? adder;
for (final m in members) {
if (m.id == item.by) adder = m;
}
if (item.how.adderOnly) {
if (adder == null) return (id: null, why: '${item.byName.isEmpty ? 'who added it' : item.byName} left');
if (adder.presence == Presence.gone) return (id: null, why: null);
return (id: adder.id, why: null);
}
final ref = item.media.jellyfin;
if (item.how == QueueHow.jellyfin && ref != null) {
bool can(MemberView m) => isPresentMember(m) && m.jf[ref.serverId] == true;
if (adder != null && can(adder)) return (id: adder.id, why: null);
final id = lowestMemberId(members.where(can));
return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null);
}
if (adder != null && isPresentMember(adder)) return (id: adder.id, why: null);
return (id: lowestMemberId(members.where(isPresentMember)), why: null);
}
/// The lowest member id of [members] ("m2" before "m10"), or null.
String? lowestMemberId(Iterable<MemberView> members) {
final ids = [for (final m in members) m.id]..sort(compareMemberIds);
return ids.isEmpty ? null : ids.first;
}
/// "m2" before "m10": numeric suffix order, then plain text.
int compareMemberIds(String a, String b) {
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.compareTo(b);
}
/// Who may add the continuation ([QueueItem.auto]) of the current media:
/// who put it up ([swapBy], a member id), or, when that member is not
/// `in`, the first member (in [members] order, the join order) who is.
bool mayAddContinuation(String memberId, String? swapBy, List<MemberView> members) {
if (memberId.isEmpty) return false;
if (memberId == swapBy) return true;
final swapper = members.where((m) => m.id == swapBy).firstOrNull;
if (swapper != null && swapper.presence == Presence.watching) return false;
return members.where((m) => m.presence == Presence.watching).firstOrNull?.id == memberId;
}
/// Whole seconds left of [advance] at [now] (server clock), at least 0.
int advanceSecondsLeft(QueueAdvance advance, DateTime now) {
final end = advance.at.add(Duration(seconds: advance.secs));
final ms = end.difference(now).inMilliseconds;
return ms <= 0 ? 0 : (ms / 1000).ceil();
}
+1 -1
View File
@@ -316,7 +316,7 @@ void main() {
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse);
});
test('state carries hold and the reserved advance', () {
test('state carries hold and advance', () {
final at = DateTime.utc(2026);
final s = StateBroadcast(
eventId: 1,
+129
View File
@@ -70,6 +70,7 @@ void main() {
});
test('skip asks for the first item; a play request times out', () {
engine.applySwap('a', const HelloMedia(name: 'up', size: 0, duration: 0, url: 'https://x/up'));
expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued');
engine.applyQueueAdd('a', link('one'));
expect(engine.applyQueuePlay('a', ''), isTrue);
@@ -171,4 +172,132 @@ void main() {
expect(engine.media!.name, 'Ep02');
});
});
group("what's next (the server's countdown)", () {
QueueItem file(String title, {String key = 'abcdefgh12'}) => QueueItem(
media: HelloMedia(name: title, size: 3, duration: 0), how: QueueHow.file, title: title, key: key);
// Both keep talking (pings), so nobody goes silent.
void after(int secs) {
t = t.add(Duration(seconds: secs));
engine
..touch('a')
..touch('b')
..tick();
}
/// Ep01 (20 s) up by alice, both ready and buffered, played to its end.
void finish() {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 20, url: 'https://example.com/1'));
for (final tok in ['a', 'b']) {
engine.applyReady(tok, true);
engine.applyReport(tok, 0, 100, engine.eventId, mediaId: engine.mediaId);
}
expect(engine.transport, Transport.playing);
after(21);
expect(engine.transport, Transport.finished);
}
test('nothing up: the first item is asked for at once', () {
engine.applyQueueAdd('b', link('one'));
expect(engine.queue.play, idOf('one'));
expect(engine.advance, isNull);
});
test('at the end with an item first: 10 s for everyone, then queuePlay', () {
finish();
expect(engine.advance, isNull, reason: 'nothing queued, nothing counts down');
engine.applyQueueAdd('b', link('two'));
final a = engine.advance!;
expect([a.id, a.secs], [idOf('two'), RelayConstants.advanceSecs]);
expect(a.at, t);
expect(engine.broadcast().advance!.id, a.id, reason: 'in every state');
expect(advanceSecondsLeft(a, t.add(const Duration(milliseconds: 2500))), 8);
after(5);
expect(engine.queue.play, isNull);
// A new first item takes the countdown over, its end unchanged.
engine.applyQueueAdd('a', link('first'), next: true);
expect([engine.advance!.id, engine.advance!.at], [idOf('first'), a.at]);
after(5);
expect(engine.queue.play, idOf('first'));
expect(engine.advance, isNull, reason: 'over once it asked');
after(1);
expect(engine.advance, isNull, reason: 'not counted down again while the request waits');
});
test('a seek stops it; cancel stops it for this media and drops the continuation', () {
engine.applyQueueAdd('b', link('two'));
finish();
expect(engine.advance, isNotNull);
engine.applySeek('b', 5);
expect(engine.advance, isNull);
expect(engine.transport, isNot(Transport.finished));
// Played to the end again: a fresh countdown.
after(16);
expect(engine.transport, Transport.finished);
expect(engine.advance, isNotNull);
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
engine.feed.drain();
expect(engine.applyQueueCancel('b'), isTrue);
expect(engine.advance, isNull);
expect(titles(), ['two']);
expect(engine.feed.drain().single.action, 'advance-cancel');
after(30);
expect((engine.advance, engine.queue.play), (null, null), reason: 'cancelled for this media');
});
test("holds while its performer is gone, plays once they're back", () {
engine.applyQueueAdd('b', file('bobs'));
finish();
engine.applyGone('b');
after(12);
expect(engine.queue.play, isNull, reason: 'held: only bob has the file');
expect(engine.advance, isNotNull);
engine.applyTakeover('b', seq: 1);
after(1);
expect(engine.queue.play, idOf('bobs'));
});
test("holds while the item's relay source is gone", () {
var sourceGone = true;
engine.itemSourceGone = (_) => sourceGone;
engine.applyQueueAdd('b', link('two'));
finish();
after(12);
expect(engine.queue.play, isNull);
sourceGone = false;
after(1);
expect(engine.queue.play, idOf('two'));
});
test('what nobody here can put up is skipped by the server', () {
engine.applyQueueAdd('b', file('bobs'));
engine.applyQueueAdd('a', link('next'));
finish();
engine.applyLeave('b', reason: 'bye');
engine.feed.drain();
after(11);
expect(titles(), ['next']);
final skip = engine.feed.drain().firstWhere((f) => f.action == 'queue-skip');
expect([skip.actorId, skip.target, skip.reason], ['server', 'bobs', 'bob left']);
expect(engine.advance!.id, idOf('next'), reason: 'the countdown goes on with the next one');
after(1);
expect(engine.queue.play, idOf('next'));
// A play request for a Jellyfin item nobody here can open: skipped too.
engine.applyQueueAdd('a', QueueItem(
media: const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')),
how: QueueHow.jellyfin,
title: 'ep'));
engine.applyQueuePlay('a', idOf('ep'));
expect(titles().contains('ep'), isFalse);
});
test('the continuation adds no feed line', () {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1'));
engine.feed.drain();
engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: engine.mediaId));
expect(engine.feed.drain().where((f) => f.action == 'queue-add'), isEmpty);
});
});
}
+16
View File
@@ -388,6 +388,22 @@ void main() {
group('swap', () {
const ep2 = HelloMedia(name: 'Ep02.mkv', size: 9, duration: 90.0);
test('a restore (after a restart) puts it back at its position, held; finished stays finished', () {
join('a', 'alice');
engine.applySwap('a', ep2, restore: const SwapRestore(position: 42.5, transport: 'playing'));
expect((engine.positionAt(), engine.transport), (42.5, Transport.held));
expect(engine.feed.drain().last.reason, 'restore');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.playing, reason: 'the gate starts it again');
engine.applySwap('a', ep2, restore: const SwapRestore(position: 90, transport: 'finished'));
expect((engine.positionAt(), engine.transport), (90.0, Transport.finished));
final rt = SwapIntent.fromJson(
const SwapIntent(media: ep2, restore: SwapRestore(position: 3, transport: 'playing')).toJson());
expect((rt.restore!.position, rt.restore!.transport), (3.0, 'playing'));
expect(SwapIntent.fromJson(const SwapIntent(media: ep2).toJson()).restore, isNull);
});
test('swap bumps mediaId, holds, keeps ready, fresh members in set', () {
join('a', 'alice');
engine.applyReady('a', true);
+127 -78
View File
@@ -1,102 +1,151 @@
// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present)
import 'package:sync_relay_core/core.dart';
import 'package: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', () {
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);
expect(w.furthest, 630);
group('time In', () {
test('counts the time it played while In; pauses, holds and jumps add nothing', () {
final w = WatchSession();
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('sat out (Away) or joining: not counted, though the media plays on (P7)', () {
final w = WatchSession();
var t = run(w, 0, 0, 120); // in for two minutes
t = run(w, t, 120, 1200, present: false); // sat out for the rest
expect(w.timeIn, closeTo(120, 2));
expect(w.verdict(runtime: ep).outcome, WatchOutcome.partial);
expect(w.furthest, closeTo(120, 1), reason: 'positions while away are not this viewer\'s');
});
test('a stall longer than the gap adds nothing', () {
final w = WatchSession();
w.sample(0, playing: true, present: true, at: _t0);
w.sample(30, playing: true, present: true, at: _t0.add(const Duration(seconds: 30)));
expect(w.timeIn, 0);
});
test('drift corrections and small seeks while watching do not cost watched time (P9)', () {
final w = WatchSession();
var pos = 0.0;
for (var i = 0; i <= 1200; i++) {
// Every 7 s the player is corrected half a second back or ahead.
if (i % 7 == 0) pos += i % 14 == 0 ? 0.5 : -0.5;
w.sample(pos, playing: true, present: true, at: _t0.add(Duration(seconds: i)));
pos += 1;
}
expect(w.timeIn, closeTo(1200, 1));
expect(w.verdict(runtime: ep).outcome, WatchOutcome.played);
});
});
test('rewatching a part counts once in coverage, twice in watched time', () {
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));
});
group('verdict (D3)', () {
test('played: 70 % of the runtime In, or the ending (or the last 15 %) reached after 40 %', () {
final full = WatchSession();
run(full, 0, 0, 1200); // 83 %
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () {
final full = WatchSession();
play(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();
var t = run(skipped, 0, 0, 90);
t = run(skipped, t, 180, 1170); // to the ED start
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
final skipped = WatchSession();
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED
play(skipped, t, ed, ed + 5);
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);
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);
});
// 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);
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 jumped = WatchSession();
play(jumped, 0, 0, 400); // 28 %, then jumped to the end
jumped.sample(ep, playing: false, at: DateTime(2026, 1, 2));
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
final shot = WatchSession();
run(shot, 0, 700, 20);
expect(shot.verdict(runtime: ep).outcome, WatchOutcome.peek);
test('played before: stays played however little was watched', () {
final w = WatchSession(playedBefore: true);
play(w, 0, 700, 730); // checking a moment
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
});
// A quarter watched, then a jump to the end: part way, not played.
final jumped = WatchSession();
t = run(jumped, 0, 0, 400);
run(jumped, t, ep - 5, 5);
expect(jumped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.partial);
});
test('a peek leaves nothing; part way resumes at the last position', () {
final peek = WatchSession(resumeBefore: 300);
play(peek, 0, 900, 990); // 90 s
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
test('played before: stays played however little was watched', () {
final w = WatchSession(playedBefore: true);
run(w, 0, 700, 30);
expect(w.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.keepPlayed);
expect(w.verdict(runtime: ep).watched, isTrue);
});
final movie = WatchSession();
play(movie, 0, 0, 300); // 5 min of 2 h: under 5 %
expect(movie.verdict(runtime: 7200).outcome, WatchOutcome.peek);
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);
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 v = part.verdict(runtime: ep);
expect(v.outcome, WatchOutcome.partial);
expect(v.resumeAt, 450);
// 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));
});
// A 3 min clip: half of it is not a peek.
final clip = WatchSession();
play(clip, 0, 0, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
});
test('a peek leaves nothing; part way resumes at the last position', () {
final peek = WatchSession(resumeBefore: 300);
run(peek, 0, 900, 90);
expect(peek.verdict(runtime: ep).outcome, WatchOutcome.peek);
test('an unknown runtime never makes it played', () {
final w = WatchSession();
play(w, 0, 0, 1000);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
final movie = WatchSession();
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 = run(part, 0, 0, 500);
part.sample(450, playing: false, present: true, at: _t0.add(Duration(seconds: t)));
final v = part.verdict(runtime: ep);
expect(v.outcome, WatchOutcome.partial);
expect(v.resumeAt, 450);
// A 3 min clip: half of it is not a peek.
final clip = WatchSession();
run(clip, 0, 0, 90);
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
});
test('an unknown runtime never makes it played', () {
final w = WatchSession();
run(w, 0, 0, 1000);
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
});
});
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
MemberView member(String id,
{Presence presence = Presence.watching, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView(
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, presence: presence, jf: jf);
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1',
media: how == QueueHow.jellyfin
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
how: how,
title: 'x',
by: by,
byName: byName,
);
void main() {
group('queuePerformer: who puts an item up (the server and every app read it the same)', () {
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
for (final how in [QueueHow.through, QueueHow.file]) {
expect(queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.away)]).id, 'm2',
reason: 'sitting out, it comes back to put its own item up');
final waits = queuePerformer(item(how), [member('m1'), member('m2', presence: Presence.gone)]);
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
expect((left.id, left.why), (null, 'Bob left'));
}
});
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
final access = {'S': true};
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
expect(
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
.id,
'm3');
expect(
queuePerformer(
item(QueueHow.jellyfin), [member('m3', jf: access, presence: Presence.away), member('m2')])
.why,
'nobody here has that Jellyfin server');
});
test('a link everyone opens or the server fetches: the adder, else the lowest present member', () {
for (final how in [QueueHow.direct, QueueHow.server]) {
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
expect(
queuePerformer(item(how), [member('m4'), member('m2', presence: Presence.gone), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', presence: Presence.away)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
test('member ids order by number: m2 before m10', () {
expect(compareMemberIds('m9', 'm10'), lessThan(0));
expect(lowestMemberId([member('m10'), member('m9')]), 'm9');
expect(lowestMemberId(const []), isNull);
});
});
test('the continuation: who put the media up, else the first member in', () {
final members = [member('m1', presence: Presence.away), member('m2'), member('m3')];
expect(mayAddContinuation('m1', 'm1', members), isTrue, reason: 'the swapper itself');
expect(mayAddContinuation('m2', 'm1', members), isTrue, reason: 'the swapper is away: the first in');
expect(mayAddContinuation('m3', 'm1', members), isFalse);
expect(mayAddContinuation('m2', 'm3', members), isFalse, reason: 'the swapper is in');
expect(mayAddContinuation('m2', null, members), isTrue, reason: 'the media the server started with');
expect(mayAddContinuation('', 'm1', members), isFalse);
});
test('seconds left of a countdown, on the server clock', () {
final at = DateTime.utc(2026, 1, 1);
final a = QueueAdvance(id: 'q1', at: at, secs: 10);
expect(advanceSecondsLeft(a, at), 10);
expect(advanceSecondsLeft(a, at.add(const Duration(milliseconds: 100))), 10);
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 9, milliseconds: 1))), 1);
expect(advanceSecondsLeft(a, at.add(const Duration(seconds: 12))), 0);
});
}