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
@@ -204,6 +204,11 @@ class SessionCoordinator {
|
||||
/// Set when this viewer cannot play the Jellyfin media: no account and
|
||||
/// no relay. The session shows it with a way to link an account.
|
||||
final jellyfinBlocked = signal<BlockedPlay?>(null);
|
||||
/// An item this app is about to put up, resolved ahead with its own
|
||||
/// account (Up next's first item, the next episode; see
|
||||
/// `QueueController`): opening it then skips PlaybackInfo. Used once,
|
||||
/// until [until].
|
||||
({JellyfinRef ref, OwnPlay plan, DateTime until})? warmJellyfin;
|
||||
/// Neighbouring episodes this viewer can put up (own account only).
|
||||
final previousEpisode = signal<EpisodeLink?>(null);
|
||||
final nextEpisode = signal<EpisodeLink?>(null);
|
||||
@@ -1620,8 +1625,9 @@ class SessionCoordinator {
|
||||
}
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = await resolveJellyfinRef(jellyfin, ref,
|
||||
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
|
||||
plan = _takeWarmJellyfin(ref) ??
|
||||
await resolveJellyfinRef(jellyfin, ref,
|
||||
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
|
||||
} finally {
|
||||
if (gen == _jfGen) _opening = false;
|
||||
}
|
||||
@@ -1656,11 +1662,27 @@ class SessionCoordinator {
|
||||
case OwnPlay(:final url, :final headers):
|
||||
jellyfinBlocked.value = null;
|
||||
if (!await _openMedia(url, headers: headers) || gen != _jfGen) return;
|
||||
// Played before and the resume point, fresh (a warmed plan's item
|
||||
// is older): the watched rule never unmarks a played item.
|
||||
final known = plan.item?.userData;
|
||||
final userDataF = plan.client
|
||||
.userData(ref.itemId)
|
||||
.timeout(const Duration(seconds: 5))
|
||||
.then<JellyfinUserData?>((d) => d, onError: (_) => known);
|
||||
unawaited(_findEpisodes(plan, gen));
|
||||
unawaited(_loadSegments(plan, ref, gen));
|
||||
// Playing carries the tracks: wait (bounded) until the file is in.
|
||||
await playback.waitForTracks();
|
||||
if (gen != _jfGen) return;
|
||||
final before = await userDataF;
|
||||
if (gen != _jfGen) return;
|
||||
final reporter = JellyfinProgressReporter(
|
||||
client: plan.client,
|
||||
itemId: ref.itemId,
|
||||
source: plan.source,
|
||||
playSessionId: plan.info.playSessionId,
|
||||
playedBefore: before?.played ?? false,
|
||||
resumeTicksBefore: before?.playbackPositionTicks ?? 0,
|
||||
onUnauthorized: () => jellyfin.markExpired(ref.serverId),
|
||||
onError: (e) {
|
||||
if (_jfToastShown) return;
|
||||
@@ -1668,11 +1690,6 @@ class SessionCoordinator {
|
||||
_pushToast('Jellyfin progress not saved: ${e is JellyfinException && e.isUnauthorized ? 'sign-in expired' : 'server unreachable'}');
|
||||
},
|
||||
);
|
||||
unawaited(_findEpisodes(plan, gen));
|
||||
unawaited(_loadSegments(plan, ref, gen));
|
||||
// Playing carries the tracks: wait (bounded) until the file is in.
|
||||
await playback.waitForTracks();
|
||||
if (gen != _jfGen) return;
|
||||
final snap = await _snapshot();
|
||||
if (gen != _jfGen || snap == null) return;
|
||||
// Installed once started: the per-state restart (after the end)
|
||||
@@ -1682,6 +1699,18 @@ class SessionCoordinator {
|
||||
await _applyTrackChoice(plan, gen);
|
||||
}
|
||||
|
||||
/// [warmJellyfin] when it is [ref], still fresh and its account
|
||||
/// unchanged.
|
||||
OwnPlay? _takeWarmJellyfin(JellyfinRef ref) {
|
||||
final w = warmJellyfin;
|
||||
if (w == null || !sameJellyfinItem(w.ref, ref)) return null;
|
||||
warmJellyfin = null;
|
||||
final fresh = DateTime.now().isBefore(w.until) &&
|
||||
jellyfin.account(ref.serverId)?.token == w.plan.account.token &&
|
||||
jellyfin.statusOf(ref.serverId) != JellyfinAccountStatus.expired;
|
||||
return fresh ? w.plan : null;
|
||||
}
|
||||
|
||||
Future<void> _findEpisodes(OwnPlay plan, int gen) async {
|
||||
final around = await findAdjacentEpisodes(plan);
|
||||
if (gen != _jfGen) return;
|
||||
@@ -1734,7 +1763,10 @@ class SessionCoordinator {
|
||||
final snap = await _snapshot();
|
||||
if (snap == null) return;
|
||||
if (reporter != null && identical(reporter, _reporter) && r.transport != 'finished') {
|
||||
reporter.update(snap);
|
||||
final ending = playback.ending.peek()?.start;
|
||||
reporter
|
||||
..timeline(duration: playback.durationSecs, endingStart: ending == null ? null : ending.inMilliseconds / 1000)
|
||||
..update(snap);
|
||||
}
|
||||
final next = nextEpisode.peek();
|
||||
final duration = playback.durationSecs;
|
||||
|
||||
Reference in new issue
Block a user