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
303 lines
10 KiB
Dart
303 lines
10 KiB
Dart
part of '../../video_player_screen.dart';
|
|
|
|
extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
|
void _onVideoCompleted(bool completed, {bool skipAutoPlayCountdown = false}) async {
|
|
// Live TV streams are continuous — ignore transient EOF events while an
|
|
// HLS playlist refreshes or crosses a discontinuity.
|
|
if (widget.isLive) return;
|
|
if (!completed) return;
|
|
// Ignore spurious EOF from the old file during an in-place media-source
|
|
// transition (episode swap, transcode restart, channel switch).
|
|
if (_playbackTransition != _PlaybackTransition.idle) return;
|
|
if (_isResolvingCompletionAdjacency) return;
|
|
|
|
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
|
|
// never fires false. Normalize all playback-dependent state.
|
|
unawaited(_wakelockController.setEnabled(false));
|
|
final duration = player?.state.duration;
|
|
unawaited(
|
|
duration != null && duration.inMilliseconds > 0
|
|
? _sendStoppedProgressOnce(positionOverride: duration)
|
|
: _sendStoppedProgressOnce(),
|
|
);
|
|
_updateMediaControlsPlaybackState();
|
|
unawaited(DiscordRPCService.instance.pausePlayback());
|
|
// The item finished, so real-time trackers get a terminal report now rather
|
|
// than whenever the screen happens to tear down: a completion prompt or
|
|
// end-of-video sleep timer can leave it open for minutes, and until then
|
|
// the service would still show the item as playing. Seed the known duration
|
|
// first — the position stream can stop a beat short of it on EOF. A later
|
|
// dispose or in-place reload finds no context and does nothing.
|
|
if (duration != null && duration.inMilliseconds > 0) {
|
|
TrackerCoordinator.instance.updatePosition(duration);
|
|
}
|
|
unawaited(TrackerCoordinator.instance.stopPlayback());
|
|
if (_autoPipEnabled) {
|
|
unawaited(_updateAutoPipState(isPlaying: false));
|
|
}
|
|
|
|
// End-of-video sleep timer takes precedence over autoplay / next-episode
|
|
// dialogs: the user explicitly asked to stop after this item.
|
|
final sleepTimerService = SleepTimerService();
|
|
if (sleepTimerService.isEndOfVideoMode && !_completionLatch.triggered) {
|
|
_completionLatch.latch();
|
|
sleepTimerService.notifyVideoCompleted();
|
|
return;
|
|
}
|
|
if (!_canNavigateMediaItems()) {
|
|
if (!_completionLatch.triggered) _completionLatch.latch();
|
|
return;
|
|
}
|
|
|
|
var navigationAction = completionNavigationAction(
|
|
hasNext: _nextEpisode != null,
|
|
adjacentStatus: _nextEpisodeStatus,
|
|
);
|
|
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
|
_isResolvingCompletionAdjacency = true;
|
|
try {
|
|
await _loadAdjacentEpisodes();
|
|
} finally {
|
|
_isResolvingCompletionAdjacency = false;
|
|
}
|
|
if (!mounted) return;
|
|
navigationAction = completionNavigationAction(hasNext: _nextEpisode != null, adjacentStatus: _nextEpisodeStatus);
|
|
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
|
_completionLatch.latch();
|
|
showGlobalErrorSnackBar(t.messages.errorLoadingSeries);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (navigationAction == CompletionNavigationAction.presentNext &&
|
|
!_showPlayNextDialog &&
|
|
!_showStillWatchingPrompt &&
|
|
!_completionLatch.triggered) {
|
|
_completionLatch.latch();
|
|
|
|
// PiP: skip dialog (user can't interact), auto-play immediately
|
|
if (PipService().isPipActive.value) {
|
|
unawaited(_playNext());
|
|
return;
|
|
}
|
|
|
|
// Capture keyboard mode before async gap
|
|
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context, listen: false);
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
if (!mounted) return;
|
|
final autoPlayEnabled = settings.read(SettingsService.autoPlayNextEpisode);
|
|
|
|
if (skipAutoPlayCountdown && autoPlayEnabled) {
|
|
unawaited(_playNext());
|
|
return;
|
|
}
|
|
|
|
_setPlayerState(() {
|
|
_showPlayNextDialog = true;
|
|
_autoPlayCountdown = autoPlayEnabled ? 5 : -1;
|
|
});
|
|
|
|
// Auto-focus Play Next button on TV when dialog appears (only in keyboard/TV mode)
|
|
if (isKeyboardMode) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_playNextConfirmFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (autoPlayEnabled) {
|
|
_startAutoPlayTimer();
|
|
}
|
|
} else if (navigationAction == CompletionNavigationAction.exit && !_completionLatch.triggered) {
|
|
_completionLatch.latch();
|
|
unawaited(_handleBackButton());
|
|
}
|
|
}
|
|
|
|
void _startAutoPlayTimer() {
|
|
if (!_canNavigateMediaItems()) return;
|
|
_autoPlayTimer?.cancel();
|
|
_autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
_setPlayerState(() {
|
|
_autoPlayCountdown--;
|
|
});
|
|
if (_autoPlayCountdown <= 0) {
|
|
timer.cancel();
|
|
_playNext();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Re-present the Play Next prompt after an EOF-driven advance failed on a
|
|
/// transient server blip (#1867).
|
|
///
|
|
/// The failed reload rolled back to the finished episode's last frame, so
|
|
/// without a prompt the screen parks black until the device sleeps — while
|
|
/// a retry seconds later typically succeeds (skipping manually did exactly
|
|
/// that). [playNextRetryPresentation] owns the decision: only EOF-driven
|
|
/// advances that failed with [PlaybackFailureReason.serverUnavailable]
|
|
/// qualify, the auto-play countdown re-fires [_playNext] up to
|
|
/// [maxPlayNextTransientRetries] times, and after that (with auto-play
|
|
/// off, or in a Watch Together session) the prompt waits for a manual
|
|
/// retry.
|
|
void _presentPlayNextRetryPrompt({required bool wasAtCompletion}) async {
|
|
if (!mounted || !_canNavigateMediaItems()) return;
|
|
if (_isLoadingNext || _showPlayNextDialog || _showStillWatchingPrompt) return;
|
|
|
|
// Capture keyboard mode before the async gap, same as _onVideoCompleted.
|
|
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context, listen: false);
|
|
final settings = await SettingsService.getInstance();
|
|
if (!mounted || _isLoadingNext || _showPlayNextDialog) return;
|
|
|
|
final presentation = playNextRetryPresentation(
|
|
wasAtCompletion: wasAtCompletion,
|
|
failureReason: _lastMediaReloadFailureReason,
|
|
hasNext: _nextEpisode != null,
|
|
autoPlayEnabled: settings.read(SettingsService.autoPlayNextEpisode),
|
|
inWatchTogetherSession: _activeWatchTogetherSession() != null,
|
|
autoRetriesUsed: _playNextTransientRetryCount,
|
|
);
|
|
if (presentation == PlayNextRetryPresentation.none) return;
|
|
|
|
// The failed reload's rollback reset the latch; re-latch so a duplicate
|
|
// EOF signal from the parked stream cannot stack a second prompt on top.
|
|
if (!_completionLatch.triggered) _completionLatch.latch();
|
|
|
|
final countdown = presentation == PlayNextRetryPresentation.countdown;
|
|
if (countdown) _playNextTransientRetryCount++;
|
|
|
|
_setPlayerState(() {
|
|
_showPlayNextDialog = true;
|
|
_autoPlayCountdown = countdown ? 5 : -1;
|
|
});
|
|
|
|
if (isKeyboardMode) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _playNextConfirmFocusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
if (countdown) _startAutoPlayTimer();
|
|
}
|
|
|
|
void _cancelAutoPlay() {
|
|
_autoPlayTimer?.cancel();
|
|
_unfocusPlayNextPrompt();
|
|
_progressTracker?.resumeAfterStoppedReport();
|
|
// Keep the latch set while playback is still parked at EOF, so duplicate
|
|
// completed signals cannot re-open this prompt. It is re-armed once playback
|
|
// seeks back clear of the end region (see the position listener) or new media loads.
|
|
_setPlayerState(() {
|
|
_showPlayNextDialog = false;
|
|
});
|
|
}
|
|
|
|
void _dismissPlaybackPromptForBack() {
|
|
if (_showPlayNextDialog) {
|
|
_cancelAutoPlay();
|
|
return;
|
|
}
|
|
if (_showStillWatchingPrompt) {
|
|
_dismissStillWatching();
|
|
}
|
|
}
|
|
|
|
/// Re-arm the end-of-video latch so Play Next can fire again. Callers
|
|
/// decide *when* it is safe to re-arm (media reloaded, or playback moved
|
|
/// back out of the end region); the latch itself refuses while a prompt
|
|
/// or countdown is active.
|
|
void _rearmCompletionLatch() {
|
|
_completionLatch.rearmIfClear(
|
|
promptVisible: _showPlayNextDialog,
|
|
countdownActive: _autoPlayTimer?.isActive == true,
|
|
);
|
|
}
|
|
|
|
void _showStillWatchingDialog() {
|
|
// Don't show if auto-play dialog is already visible
|
|
if (_showPlayNextDialog) return;
|
|
|
|
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context, listen: false);
|
|
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = true;
|
|
_stillWatchingCountdown = 30;
|
|
});
|
|
|
|
if (isKeyboardMode) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _stillWatchingContinueFocusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
_stillWatchingTimer?.cancel();
|
|
_stillWatchingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
_setPlayerState(() {
|
|
_stillWatchingCountdown--;
|
|
});
|
|
if (_stillWatchingCountdown <= 0) {
|
|
timer.cancel();
|
|
_onStillWatchingTimeout();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onStillWatchingTimeout() {
|
|
_unfocusStillWatchingPrompt();
|
|
final currentPlayer = player;
|
|
if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
|
|
void _onStillWatchingContinue() {
|
|
_stillWatchingTimer?.cancel();
|
|
_unfocusStillWatchingPrompt();
|
|
SleepTimerService().restartTimer();
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
|
|
void _onStillWatchingPause() {
|
|
_stillWatchingTimer?.cancel();
|
|
_unfocusStillWatchingPrompt();
|
|
final currentPlayer = player;
|
|
if (currentPlayer != null) unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
|
|
void _dismissStillWatching() {
|
|
_stillWatchingTimer?.cancel();
|
|
if (_showStillWatchingPrompt) {
|
|
_unfocusStillWatchingPrompt();
|
|
_setPlayerState(() {
|
|
_showStillWatchingPrompt = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _unfocusPlayNextPrompt() {
|
|
_playNextCancelFocusNode.unfocus();
|
|
_playNextConfirmFocusNode.unfocus();
|
|
}
|
|
|
|
void _unfocusStillWatchingPrompt() {
|
|
_stillWatchingPauseFocusNode.unfocus();
|
|
_stillWatchingContinueFocusNode.unfocus();
|
|
}
|
|
}
|