Files
plezy/lib/screens/video_player/completion_latch.dart
T
edde746 7cd5bb64bb refactor(player): remove dead completion-latch channels, unreachable settings states, and unread DV stats
classifyPosition's return enum was discarded by its only production caller (rearm is the side effect that matters); the latch's tolerance/retry knobs were never tuned; _loadAdjacentEpisodes' result was unread by all three callers; countsAsApplied carried a subsumed disjunct and _syncMediaControlsAvailability a stale-generation guard with no suspension point before it; _SettingsView.audioSync/subtitleSync could never be current (both routes early-return into the sync bar); PerformanceStats.dvRpuOutputTooSmall/dvPlaybackReason and ContentStrip.chaptersLoaded were written but never read.
2026-08-17 19:02:24 +02:00

154 lines
6.6 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,
}) {
if (!wasAtCompletion || !hasNext) return PlayNextRetryPresentation.none;
if (failureReason != PlaybackFailureReason.serverUnavailable) {
return PlayNextRetryPresentation.none;
}
final autoRetry = autoPlayEnabled && !inWatchTogetherSession && autoRetriesUsed < maxPlayNextTransientRetries;
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,
}) {
final effectiveDurationMs = math.max(playerDurationMs, metadataDurationMs ?? 0);
if (effectiveDurationMs <= 0) return EofSignalClass.unknown;
return positionMs >= effectiveDurationMs - spuriousEofToleranceMs ? EofSignalClass.genuine : EofSignalClass.spurious;
}
/// 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, re-arming
/// the latch once playback moves back out past [rearmWindowMs] from the
/// end (and no prompt or countdown owns the screen).
void classifyPosition({
required int positionMs,
required int durationMs,
required bool promptVisible,
required bool countdownActive,
}) {
if (durationMs <= 0) return;
if (positionMs < durationMs - rearmWindowMs) {
rearmIfClear(promptVisible: promptVisible, countdownActive: countdownActive);
}
}
}