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);
}
}