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:
foxyzandClaude Opus 5.5 committed 2026-10-11 07:53:42 +03:00
1 parent 6b10cae4e6
commit 771b961b32
27 files changed
+1396 -558

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ export 'src/cookies.dart';
export 'src/media_source.dart';
export 'src/sync_engine.dart';
export 'src/updater.dart';
export 'src/watch_session.dart';
export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart';
+12 -6
View File
@@ -92,7 +92,9 @@ class JellyfinPlaybackReport {
final String itemId;
final String mediaSourceId;
final String playSessionId;
final int positionTicks;
/// Null leaves it out: a Progress without it does not touch the saved
/// position or played state; a Stopped without it marks the item played.
final int? positionTicks;
final bool isPaused;
/// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track.
final int? audioStreamIndex;
@@ -136,7 +138,7 @@ class JellyfinPlaybackReport {
'ItemId': itemId,
'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId,
'PositionTicks': positionTicks,
if (positionTicks != null) 'PositionTicks': positionTicks,
'IsPaused': isPaused,
'CanSeek': canSeek,
'PlayMethod': playMethod,
@@ -149,7 +151,7 @@ class JellyfinPlaybackReport {
'ItemId': itemId,
'MediaSourceId': mediaSourceId,
'PlaySessionId': playSessionId,
'PositionTicks': positionTicks,
if (positionTicks != null) 'PositionTicks': positionTicks,
};
}
@@ -540,19 +542,23 @@ class JellyfinClient {
// ------------------------------------------------------------ progress
/// `POST /Sessions/Playing`: playback started (Jellyfin shows the session
/// as playing and starts tracking the item).
/// as playing and starts tracking the item). Up to 10.11 this also sets
/// the item unplayed (resumable items); later servers leave it.
Future<void> reportPlaybackStart(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing', body: r.toProgressJson());
/// `POST /Sessions/Playing/Progress`: position, pause state and tracks.
/// Jellyfin saves the resume position from these.
/// With a position Jellyfin saves it like a stop does (below
/// "min resume" cleared, past "max resume" marked played); without one
/// the saved position and played state stay.
Future<void> reportPlaybackProgress(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson());
/// `POST /Sessions/Playing/Stopped`: ends the session. The server then
/// marks the item played if the position is past its "max resume" share
/// (90% by default), keeps it as a resume point between 5% and 90%, and
/// clears the position below 5%.
/// clears the position below 5% (the played flag stays as it is in both).
/// Without a position it marks the item played and clears the position.
Future<void> reportPlaybackStopped(JellyfinPlaybackReport r) =>
_json('POST', '/Sessions/Playing/Stopped', body: r.toStoppedJson());
+132
View File
@@ -0,0 +1,132 @@
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".
/// What a [WatchSession] amounts to when it ends.
enum WatchOutcome {
/// Seen: mark it played and clear the resume point.
played,
/// Already played when opened (or earlier in this session): stays played
/// whatever happened, with no resume point.
keepPlayed,
/// Barely watched (a peek, a screenshot): leave the item as it was.
peek,
/// Part way: resume from [WatchVerdict.resumeAt].
partial,
}
class WatchVerdict {
final WatchOutcome outcome;
/// Where to resume, seconds ([WatchOutcome.partial] only).
final double? resumeAt;
const WatchVerdict(this.outcome, {this.resumeAt});
@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.
class WatchSession {
/// Played before this opening (Jellyfin `UserData.Played`).
final bool playedBefore;
/// The resume point before this opening, seconds (0 = none).
final 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});
DateTime? startedAt;
DateTime? endedAt;
double? firstPosition;
double? lastPosition;
/// The furthest position seen, reached by playing or by a jump.
double furthest = 0;
/// Seconds of normal playback, rewatches included.
double watched = 0;
final _spans = <(double, double)>[];
({double pos, bool playing, DateTime at})? _prev;
/// 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);
}
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);
}
/// 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] >= 80 % of the runtime, or the ending was reached
/// ([furthest]) with [covered] >= 50 %: [WatchOutcome.played];
/// - [covered] 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;
if (runtime > 0) {
final end = endingStart != null && endingStart > 0 && endingStart < runtime ? endingStart : runtime - 2;
if (c >= 0.8 * runtime || (furthest >= end && c >= 0.5 * 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);
return WatchVerdict(WatchOutcome.partial, resumeAt: lastPosition ?? 0);
}
}
@@ -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);
});
}