Files
plezy/lib/screens/video_player/completion_latch.dart
T
edde746 6663353895 fix(player): retry episode advances that fail on a transient server blip
An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.

Three changes:

- A failed in-place reload now records its classified failure reason, and
  an advance that ran with the completion latch set re-presents the Play
  Next prompt when that reason is serverUnavailable. With auto-play
  enabled the countdown re-fires the advance up to two times before the
  prompt goes manual-only; Watch Together sessions and mid-episode Next
  presses (whose rolled-back stream is still valid) keep the existing
  handling. playNextRetryPresentation owns the decision and is unit-tested.

- Committing adjacency now best-effort prefetches the next episode's full
  metadata row through fetchItem, which writes the exact row playback
  initialization falls back to on both backends (Plex: same cache key and
  full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
  playback bundle reads). A warm row turns a blip at the transition into a
  normal start.

- JellyfinClient.fetchItem's documented "pure transport error -> cached
  row" fallback was dead code: the HTTP layer wraps transport errors into
  MediaServerHttpException, which the first catch rethrew unconditionally.
  Status-less, non-cancelled failures now take the fallback; answered
  requests (401/403/5xx) and cancellations surface unchanged.

Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.

close #1867
2026-08-11 09:08:07 +02:00

166 lines
6.9 KiB
Dart

import 'dart:math' as math;
import '../../providers/playback_state_provider.dart';
import '../../services/playback_initialization_types.dart';
/// Position must be within this many ms of the best-known duration for a
/// player EOF signal to count as the real end of the media.
///
/// Wide enough that a transcode container ending a couple of seconds short
/// of the server metadata duration still classifies as genuine, yet a
/// spurious EOF that slips through inside the window lands where servers
/// already mark the item watched (~90%), so the user outcome is unchanged.
/// The failure this guards against (#1520) parks playback minutes short.
const int spuriousEofToleranceMs = 10000;
/// How a player EOF signal should be interpreted.
enum EofSignalClass { genuine, spurious, unknown }
/// End-of-media action after considering queue adjacency discovery.
enum CompletionNavigationAction { presentNext, retryAdjacent, exit }
CompletionNavigationAction completionNavigationAction({
required bool hasNext,
required QueueNavigationStatus adjacentStatus,
}) {
if (hasNext) return CompletionNavigationAction.presentNext;
if (adjacentStatus == QueueNavigationStatus.failed) {
return CompletionNavigationAction.retryAdjacent;
}
return CompletionNavigationAction.exit;
}
/// How many times an auto-play countdown may re-fire a transiently failed
/// EOF advance before the Play Next prompt goes manual-only (#1867). Retries
/// are spaced by the countdown plus the failed attempt itself (connect
/// timeout + endpoint failover), so two retries cover a short connectivity
/// blip without looping against a server that is genuinely down.
const int maxPlayNextTransientRetries = 2;
/// How a failed EOF-driven advance should be re-presented to the user.
enum PlayNextRetryPresentation {
/// Keep the existing failure handling (rollback + error snackbar).
none,
/// Re-present the Play Next prompt without a countdown — retry is the
/// user's move.
manual,
/// Re-present the Play Next prompt with the auto-play countdown so the
/// advance retries by itself.
countdown,
}
/// Decide whether a failed episode advance re-presents the Play Next prompt.
///
/// A transient server blip at the exact moment of an EOF transition used to
/// park the screen on the finished episode's last frame with no way forward
/// but the transport controls (#1867) — while a retry seconds later
/// typically succeeds. Only EOF-driven advances qualify: a mid-episode Next
/// press rolls back to a still-valid playing stream, and non-transient
/// failures (missing file, auth) must not retry-loop. Watch Together
/// sessions never auto-retry — the sync layer owns transitions — but the
/// manual prompt remains available, matching the Next button.
PlayNextRetryPresentation playNextRetryPresentation({
required bool wasAtCompletion,
required PlaybackFailureReason? failureReason,
required bool hasNext,
required bool autoPlayEnabled,
required bool inWatchTogetherSession,
required int autoRetriesUsed,
int maxAutoRetries = maxPlayNextTransientRetries,
}) {
if (!wasAtCompletion || !hasNext) return PlayNextRetryPresentation.none;
if (failureReason != PlaybackFailureReason.serverUnavailable) {
return PlayNextRetryPresentation.none;
}
final autoRetry = autoPlayEnabled && !inWatchTogetherSession && autoRetriesUsed < maxAutoRetries;
return autoRetry ? PlayNextRetryPresentation.countdown : PlayNextRetryPresentation.manual;
}
/// Classify a player EOF signal against the best-known media duration.
///
/// mpv reports a clean EOF when a network stream dies mid-file (a reaped
/// transcode session or an idle connection closed during a long pause), so
/// the signal alone cannot be trusted — position is the only discriminator.
///
/// [playerDurationMs] alone is not trustworthy either: on chunked transcode
/// streams the player's duration can be unknown or track the growing demuxer
/// cache (i.e. equal the parked position), making every spurious EOF look
/// genuine. [metadataDurationMs] (the server's item duration) anchors the
/// comparison; max() of the two also covers the opposite failure — server
/// metadata understating the real file length.
EofSignalClass classifyEofSignal({
required int positionMs,
required int playerDurationMs,
required int? metadataDurationMs,
int toleranceMs = spuriousEofToleranceMs,
}) {
final effectiveDurationMs = math.max(playerDurationMs, metadataDurationMs ?? 0);
if (effectiveDurationMs <= 0) return EofSignalClass.unknown;
return positionMs >= effectiveDurationMs - toleranceMs ? EofSignalClass.genuine : EofSignalClass.spurious;
}
/// What a position tick means for the end-of-video prompt flow.
enum CompletionLatchSignal {
/// Nothing to do.
none,
/// Playback moved back out of the end region and the latch re-armed.
rearmed,
}
/// End-of-video latch with rearm hysteresis for the Play Next / completion
/// prompts.
///
/// Completion itself comes from the player's EOF signal. The latch prevents
/// that handling from re-running while playback is parked at EOF, and re-arms
/// only once playback moves back out past [rearmWindowMs] from the end. It
/// never re-arms while a prompt is visible or an auto-play countdown owns the
/// screen.
///
/// Latching is the *caller's* move ([latch]), not [classifyPosition]'s: the EOF
/// handler has its own bail-outs (live TV, in-flight media swap) and a signal
/// that bails must stay un-latched so the next EOF signal retries.
class CompletionLatch {
CompletionLatch({required this.rearmWindowMs});
/// Re-arm only after moving back out past this many ms from the end.
final int rearmWindowMs;
bool _triggered = false;
/// Whether the end-of-video handling already ran for this approach to
/// the end region.
bool get triggered => _triggered;
/// Mark the completion handling as done for this approach to the end.
void latch() => _triggered = true;
/// Clear unconditionally — new media was loaded.
void reset() => _triggered = false;
/// Re-arm so the prompt can fire again — but only when no prompt is
/// visible and no auto-play countdown is running, so an active dialog is
/// never clobbered. Callers decide *when* re-arming is safe (media
/// reloaded, or playback moved back out of the end region).
void rearmIfClear({required bool promptVisible, required bool countdownActive}) {
if (_triggered && !promptVisible && !countdownActive) _triggered = false;
}
/// Classify a position tick against the trigger/rearm windows.
CompletionLatchSignal classifyPosition({
required int positionMs,
required int durationMs,
required bool promptVisible,
required bool countdownActive,
}) {
if (durationMs <= 0) return CompletionLatchSignal.none;
if (positionMs < durationMs - rearmWindowMs) {
final wasLatched = _triggered;
rearmIfClear(promptVisible: promptVisible, countdownActive: countdownActive);
if (wasLatched && !_triggered) return CompletionLatchSignal.rearmed;
}
return CompletionLatchSignal.none;
}
}