Restructure step 6: what's next and watch tracking
- What's next is the server's: state.advance counts down (10 s) when the media finishes with an Up next item first, holds while its performer or warm source is gone, and sets queuePlay at its end; cancel is shared, a seek or swap ends it; the server skips items nobody can put up. Shared rules in core whats_next.dart; the app's janitor, per-screen countdown and end-of-episode card are gone. - The continuation (next Jellyfin episode, next file of a local show with its gaps) is an auto item under "Continues with", prepared like Up next's first item. - Watch tracking by time In (D3): only time played while this viewer is in counts; away, paused, held, jumps and stalls never do (P7, P9). WatchTracker + device WatchHistory (files, links); Jellyfin progress reads the same watch; records are the AniList seam. - A server restart no longer loses what played: apps hold their media and one offers it back (swap.restore, first offer wins), Up next is re-added, the source re-feeds, and the watch carries on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a87ee98faa
commit
741b258ac1
55 files changed
+2724
-964
No files matched your search
@@ -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', () {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user