Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode
- Overlay: one layout for both designs (top bar with Up next as one of its buttons; one action band above the controls for the status pill, skips, next episode and end cards), one glass recipe (AppGlass) and one pill (FloatingPill); hovering any of them, or the open queue, holds the controls up. - Up next: the first item (or the first continuation episode) is kept resolved on the app that puts it up, refreshed before it expires; Schedule and drop/paste can queue too. - Jellyfin: WatchSession (core) counts real watched coverage; an episode is watched at 80 % or at its ending after half; a played one is re-marked after Playing (10.11 unmarks on open) and never gets a position; a peek leaves the old resume point; part-way resume points stay under 85 %. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b10cae4e6
commit
771b961b32
27 files changed
+1396
-558
No files matched your search
@@ -0,0 +1,95 @@
|
||||
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)));
|
||||
}
|
||||
return t + 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);
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
test('played: 80 % covered, or the ending reached after 50 %', () {
|
||||
final full = WatchSession();
|
||||
play(full, 0, 0, 1200); // 83 %
|
||||
expect(full.verdict(runtime: ep, endingStart: ed).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 only the real end counts.
|
||||
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.partial);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
final movie = WatchSession();
|
||||
play(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 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);
|
||||
expect(clip.verdict(runtime: 180).outcome, WatchOutcome.partial);
|
||||
});
|
||||
|
||||
test('an unknown runtime never makes it played', () {
|
||||
final w = WatchSession();
|
||||
play(w, 0, 0, 1000);
|
||||
expect(w.verdict(runtime: 0).outcome, WatchOutcome.partial);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user