diff --git a/lib/screens/video_player/completion_latch.dart b/lib/screens/video_player/completion_latch.dart index 8cffeb70..e9ab7426 100644 --- a/lib/screens/video_player/completion_latch.dart +++ b/lib/screens/video_player/completion_latch.dart @@ -1,5 +1,6 @@ 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. @@ -28,6 +29,54 @@ CompletionNavigationAction completionNavigationAction({ 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 diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 85f08180..2c9050cc 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -53,6 +53,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (!mounted) return; if (_nextEpisode == null || _isLoadingNext) return; + // EOF-driven advances (prompt confirm, auto-play countdown, PiP) run with + // the completion latch set; a mid-episode Next press does not. Captured + // before the prompt state below is cleared — a transiently failed advance + // from EOF re-presents the Play Next prompt instead of parking on the + // finished episode's last frame (#1867). + final wasAtCompletion = _completionLatch.triggered; + _autoPlayTimer?.cancel(); _unfocusPlayNextPrompt(); _dismissStillWatching(); @@ -64,7 +71,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _showPlayNextDialog = false; }); - await _navigateToEpisode(_nextEpisode!); + final outcome = await _navigateToEpisode(_nextEpisode!); + if (outcome == _MediaReloadOutcome.failed) { + _presentPlayNextRetryPrompt(wasAtCompletion: wasAtCompletion); + } } Future _playPrevious() async { @@ -132,11 +142,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } /// Navigates to a new episode by reusing the current player whenever possible. - Future _navigateToEpisode(MediaItem episodeMetadata) async { + /// + /// Returns the reload outcome so [_playNext] can distinguish a failed + /// in-place swap (previous session still on screen) from rejected or + /// superseded attempts. The screen-replacement fallback reports + /// [_MediaReloadOutcome.rejected]: no in-place reload ran. + Future<_MediaReloadOutcome> _navigateToEpisode(MediaItem episodeMetadata) async { + _lastMediaReloadFailureReason = null; final currentPlayer = player; if (currentPlayer == null) { if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata)); - return; + return _MediaReloadOutcome.rejected; } // Callers fire this without awaiting (auto-play countdown, PiP, the prompt), so an escaping @@ -174,7 +190,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { nativeTrack: currentPlayer.state.track.secondarySubtitle, sessionPreference: _sessionSecondarySubtitlePreference, ); - await _reloadMediaInPlace( + return await _reloadMediaInPlace( metadata: episodeMetadata, selectedMediaIndex: _effectiveSelectedMediaIndex, selectedMediaSourceId: null, @@ -193,6 +209,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace); _clearEpisodeLoadingFlags(); if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + return _MediaReloadOutcome.failed; } } @@ -939,6 +956,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _nextEpisode = null; _previousEpisode = null; _nextEpisodeStatus = QueueNavigationStatus.failed; + // A successful swap restores the transient-retry budget for the + // next transition (#1867). + _playNextTransientRetryCount = 0; }); try { @@ -956,6 +976,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { return _MediaReloadOutcome.opened; } catch (e) { if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + // Record the classified reason so _playNext can re-present the Play + // Next prompt when an EOF-driven advance merely hit a transient + // server blip (#1867). Non-PlaybackException throws stay null — + // they never qualify for a retry prompt. + _lastMediaReloadFailureReason = e is PlaybackException ? e.reason : null; _completionLatch.reset(); if (!didOpenReplacement) { // Nothing was opened: the previous session is still committed, so diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index c841f5fa..b19cae9a 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -149,5 +149,34 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { _previousEpisode = adjacentEpisodes.previous; _nextEpisodeStatus = adjacentEpisodes.nextStatus; }); + _primeNextEpisodePlaybackMetadata(adjacentEpisodes.next); + } + + /// Best-effort prefetch of the next episode's full metadata row into the + /// API cache while the current episode plays (#1867). + /// + /// Adjacency comes from queue containers, so the per-item metadata row + /// (Plex `/library/metadata/{id}`, Jellyfin `/Users/{uid}/Items/{id}`) is + /// cold at the exact moment the transition needs it. Both backends' + /// [MediaServerClient.fetchItem] fetch network-first and write that same + /// row — the one playback initialization falls back to when the server is + /// transiently unreachable — so a warm row turns a connectivity blip at + /// the transition into a normal start instead of a failed advance. + /// + /// Documented best-effort: the transition path performs its own fetch and + /// error handling, so a failed prime costs nothing. + void _primeNextEpisodePlaybackMetadata(MediaItem? next) { + if (next == null || _offlineLibraryMode || !mounted) return; + if (_primedNextEpisodeGlobalKey == next.globalKey) return; + final client = context.tryGetMediaClientForServer(serverIdOrNull(next.serverId)); + if (client == null) return; + _primedNextEpisodeGlobalKey = next.globalKey; + unawaited(() async { + try { + await client.fetchItem(next.id); + } catch (e) { + appLogger.d('Next-episode metadata prime failed', error: e); + } + }()); } } diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 068b2285..e7cdaf16 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -134,6 +134,58 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { }); } + /// 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(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ff4b830a..ea9ee715 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -461,6 +461,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Retryable sentinel until the fire-and-forget initial adjacency load // commits found, boundary, or unavailable. QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed; + // globalKey of the adjacent episode whose playback metadata row was last + // prefetched into the API cache — see _primeNextEpisodePlaybackMetadata. + String? _primedNextEpisodeGlobalKey; bool _isResolvingCompletionAdjacency = false; bool _isLoadingNext = false; bool _isLoadingPrevious = false; @@ -568,6 +571,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin Timer? _autoPlayTimer; int _autoPlayCountdown = 5; + // Transient episode-transition failure retry (#1867). A failed in-place + // reload records the classified reason here so _playNext can distinguish + // "server momentarily unreachable" (re-present the Play Next prompt, + // optionally with an auto-retry countdown) from terminal failures. + // _navigateToEpisode clears the field before each attempt; the counter + // resets when a reload succeeds. + PlaybackFailureReason? _lastMediaReloadFailureReason; + int _playNextTransientRetryCount = 0; + // End-of-video Play Next latch. Completion comes from the player EOF signal; // position ticks only re-arm once playback is more than 2s from the end. final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000); diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index e74a27e9..6dca388f 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -758,20 +758,39 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { return _mapItem(data); } on MediaServerHttpException catch (e) { if (e.statusCode == 404) return null; + // An answered request (401/403/5xx) or a client-side cancellation must + // surface as-is. Pure transport failures (dead socket, DNS, connect + // timeout) carry no status code — and the HTTP layer wraps them into + // [MediaServerHttpException], so they arrive here rather than in the + // generic catch below. Apply the documented cache fallback (#1867). + if (e.statusCode != null || e.isCancellation) rethrow; + appLogger.w('JellyfinClient.fetchItem network call failed', error: e); + final cached = await _cachedItemFallback(endpoint); + if (cached != null) return cached; rethrow; } catch (e) { - // Transport-layer failure: socket error, DNS, TLS, etc. Try cache. + // Non-HTTP failure while handling the response (e.g. mapping). Same + // best-effort fallback before surfacing. appLogger.w('JellyfinClient.fetchItem network call failed', error: e); - try { - final cached = await cache.get(ServerId(cacheServerId), endpoint); - if (cached is Map) return _mapItem(cached); - } catch (cacheError, st) { - appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st); - } + final cached = await _cachedItemFallback(endpoint); + if (cached != null) return cached; rethrow; } } + /// Best-effort cached-row read for [_fetchItemOnce]'s failure fallbacks. + /// Returns null on miss or on a cache/mapping error (logged) so the caller + /// rethrows its original failure. + Future _cachedItemFallback(String endpoint) async { + try { + final cached = await cache.get(ServerId(cacheServerId), endpoint); + if (cached is Map) return _mapItem(cached); + } catch (e, st) { + appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: e, stackTrace: st); + } + return null; + } + @override Future> fetchChildren(String parentId) => _fetchChildrenInternal(parentId); diff --git a/test/screens/video_player/completion_latch_test.dart b/test/screens/video_player/completion_latch_test.dart index 752069fc..516ade28 100644 --- a/test/screens/video_player/completion_latch_test.dart +++ b/test/screens/video_player/completion_latch_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/video_player/completion_latch.dart'; +import 'package:plezy/services/playback_initialization_types.dart'; void main() { CompletionLatch latch() => CompletionLatch(rearmWindowMs: 2000); @@ -160,4 +161,59 @@ void main() { ); }); }); + + group('playNextRetryPresentation', () { + PlayNextRetryPresentation resolve({ + bool wasAtCompletion = true, + PlaybackFailureReason? failureReason = PlaybackFailureReason.serverUnavailable, + bool hasNext = true, + bool autoPlayEnabled = true, + bool inWatchTogetherSession = false, + int autoRetriesUsed = 0, + }) { + return playNextRetryPresentation( + wasAtCompletion: wasAtCompletion, + failureReason: failureReason, + hasNext: hasNext, + autoPlayEnabled: autoPlayEnabled, + inWatchTogetherSession: inWatchTogetherSession, + autoRetriesUsed: autoRetriesUsed, + ); + } + + test('transient failure at EOF re-presents with a countdown', () { + expect(resolve(), PlayNextRetryPresentation.countdown); + }); + + test('a mid-episode Next press keeps the plain failure handling', () { + // The rolled-back stream is still valid and playing there — no prompt. + expect(resolve(wasAtCompletion: false), PlayNextRetryPresentation.none); + }); + + test('non-transient failures never retry-loop', () { + for (final reason in PlaybackFailureReason.values) { + if (reason == PlaybackFailureReason.serverUnavailable) continue; + expect(resolve(failureReason: reason), PlayNextRetryPresentation.none, reason: '$reason'); + } + // Pre-classification throws carry no reason at all. + expect(resolve(failureReason: null), PlayNextRetryPresentation.none); + }); + + test('no next episode means nothing to re-present', () { + expect(resolve(hasNext: false), PlayNextRetryPresentation.none); + }); + + test('the countdown budget exhausts into a manual prompt', () { + expect(resolve(autoRetriesUsed: maxPlayNextTransientRetries - 1), PlayNextRetryPresentation.countdown); + expect(resolve(autoRetriesUsed: maxPlayNextTransientRetries), PlayNextRetryPresentation.manual); + }); + + test('auto-play off presents the prompt without a countdown', () { + expect(resolve(autoPlayEnabled: false), PlayNextRetryPresentation.manual); + }); + + test('Watch Together sessions never auto-retry but keep the manual prompt', () { + expect(resolve(inWatchTogetherSession: true), PlayNextRetryPresentation.manual); + }); + }); } diff --git a/test/services/jellyfin_playback_bundle_test.dart b/test/services/jellyfin_playback_bundle_test.dart index a90f9f79..2e0d95f5 100644 --- a/test/services/jellyfin_playback_bundle_test.dart +++ b/test/services/jellyfin_playback_bundle_test.dart @@ -227,5 +227,35 @@ void main() { expect(bundle!.chapters, isEmpty); client.close(); }); + + test('fetchItem-primed row serves fetchPlaybackBundle across a transport failure (#1867)', () async { + final body = jsonEncode({ + 'Id': 'item-9', + 'Type': 'Movie', + 'MediaSources': [ + {'Id': 'src-9', 'Container': 'mkv'}, + ], + }); + var failNetwork = false; + final client = testJellyfinClient( + connection: _conn(), + handler: (_) async { + if (failNetwork) throw http.ClientException('connect refused'); + return http.Response(body, 200, headers: {'content-type': 'application/json'}); + }, + ); + addTearDown(client.close); + + // Adjacency discovery primes the per-item row. + expect(await client.fetchItem('item-9'), isNotNull); + + // A pure transport failure (wrapped by the HTTP layer into a + // status-less MediaServerHttpException) must fall back to that row + // instead of failing the transition. + failNetwork = true; + final bundle = await client.fetchPlaybackBundle('item-9'); + expect(bundle, isNotNull); + expect(bundle!.selectedSourceId, 'src-9'); + }); }); } diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 55558140..05460faa 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -1636,6 +1636,39 @@ void main() { expect(data.videoUrl, contains('/library/parts/10/file.mkv')); }); + test('fetchItem primes the row a transiently failing playback fetch falls back to (#1867)', () async { + var failNetwork = false; + final client = makeClient((request) async { + if (failNetwork) { + throw MediaServerHttpException( + type: MediaServerHttpErrorType.connectionTimeout, + message: 'connect timed out', + ); + } + return http.Response(jsonEncode(playableBody()), 200, headers: {'content-type': 'application/json'}); + }); + addTearDown(client.close); + + // Cold row: a connectivity blip at the transition surfaces as a + // transient failure — this is the dead-end from the issue. + failNetwork = true; + await expectLater( + client.getVideoPlaybackData('42'), + throwsA(isA().having((error) => error.isTransient, 'isTransient', isTrue)), + ); + + // Adjacency discovery primes the row through fetchItem: same cache key, + // same full playback query shape. + failNetwork = false; + expect(await client.fetchItem('42'), isNotNull); + + // The same blip now falls back to the primed row and playback proceeds. + failNetwork = true; + final data = await client.getVideoPlaybackData('42'); + expect(data.hasValidVideoUrl, isTrue); + expect(data.videoUrl, contains('/library/parts/10/file.mkv')); + }); + test('external URL and download resolution propagate typed request failures', () async { final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})); addTearDown(client.close);