forked from Foxyz/sync_replay_project
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
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user