diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index 64fbcab0..96ce3c50 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -14,51 +14,46 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { appLogger.e('[Player ERROR] ${err.message}'); if (!mounted || _isExiting.value) return; - // Fatal, unrecoverable until server-side fix — show modal instead of a snackbar. - // // A sidecar subtitle fetch can also log a status, but it never raises the - // end-file error this handler is wired to, so the status observed here - // belongs to the primary media open. - if (err.cause == PlayerError.serverHttp500 || _fatalHttpStatuses.contains(500)) { - _hasFatalPlaybackError = true; - _progressTracker?.stopTracking(); - unawaited(_showServerLimitDialog()); - return; - } + // end-file error this handler is wired to, so a latched status belongs to + // the primary media open. + final action = resolvePlaybackFailureAction( + cause: err.cause, + fatalHttpStatuses: _fatalHttpStatuses, + isLive: widget.isLive, + liveRetrying: _live.retrying, + liveFallbackLevel: _live.fallbackLevel, + liveRetryFailed: _live.retryFailed, + ); - // The server resolved the item but could not read the file behind it. No - // retry, quality change, or backend switch recovers that, and the raw mpv - // line ("Failed to open ") tells the user nothing actionable. - if (err.cause == PlayerError.serverHttp404 || _fatalHttpStatuses.contains(404)) { - _hasFatalPlaybackError = true; - _progressTracker?.stopTracking(); - unawaited(_showMediaUnreadableDialog()); - return; - } - - // Live TV: retry with progressively degraded stream settings - // (mirrors Plex web client fallback chain). - if (widget.isLive) { + switch (action) { + // Both dialogs are unrecoverable until the server side changes, so they + // replace the snackbar rather than joining it. + case PlaybackFailureAction.serverLimitDialog: + _hasFatalPlaybackError = true; + _progressTracker?.stopTracking(); + unawaited(_showServerLimitDialog()); + case PlaybackFailureAction.mediaUnreadableDialog: + _hasFatalPlaybackError = true; + _progressTracker?.stopTracking(); + unawaited(_showMediaUnreadableDialog()); // The bounded retry operation owns errors raised while applying/opening // its replacement stream. Do not let the same error close the route. - if (_live.retrying) return; - if (_live.fallbackLevel < 2) { + case PlaybackFailureAction.ignore: + return; + case PlaybackFailureAction.liveRetry: _live.fallbackLevel++; _live.retrying = true; appLogger.w('Live stream failed, retrying with fallback level ${_live.fallbackLevel}'); unawaited(_retryLiveStream()); - return; - } - if (_live.retryFailed) { + case PlaybackFailureAction.liveInterrupted: showGlobalErrorSnackBar(t.messages.liveStreamInterrupted); - return; - } + case PlaybackFailureAction.fatal: + _hasFatalPlaybackError = true; + _progressTracker?.stopTracking(); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); + unawaited(_handleBackButton()); } - - _hasFatalPlaybackError = true; - _progressTracker?.stopTracking(); - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); - unawaited(_handleBackButton()); } void _onPlayerLog(PlayerLog log) { diff --git a/lib/screens/video_player/playback_failure_action.dart b/lib/screens/video_player/playback_failure_action.dart new file mode 100644 index 00000000..4f708f9c --- /dev/null +++ b/lib/screens/video_player/playback_failure_action.dart @@ -0,0 +1,61 @@ +import '../../mpv/models.dart'; + +/// Highest fallback level the live-TV ladder will climb before giving up. +const int maxLiveFallbackLevel = 2; + +/// What the player screen should do about one playback error. +enum PlaybackFailureAction { + /// Server rejected the session with HTTP 500 — a bandwidth/transcoding limit. + serverLimitDialog, + + /// Server could not read the file behind the item (HTTP 404). + mediaUnreadableDialog, + + /// A live retry already owns the player and its error UI. + ignore, + + /// Climb one rung of the live-TV fallback ladder. + liveRetry, + + /// Live ladder is exhausted and its last retry failed. + liveInterrupted, + + /// Show the raw player error and leave the route. + fatal, +} + +/// Decides what [cause] plus the statuses seen on this open mean for playback. +/// +/// Pure so the policy is testable without a live player screen, mirroring +/// [runLiveStreamRetry]. [fatalHttpStatuses] is the set of +/// [fatalPlaybackHttpStatuses] entries the player's log stream reported. +/// +/// Live TV deliberately diverges on 404: an HLS segment that has rolled off the +/// playlist, or a transcode session restarting under us, answers 404 mid-stream, +/// and the bounded ladder exists to ride that out. Only on-demand playback +/// treats 404 as terminal, where it does mean the file is unreadable. 500 stays +/// terminal for both — a limit rejection is not something a retry clears. +PlaybackFailureAction resolvePlaybackFailureAction({ + required String? cause, + required Set fatalHttpStatuses, + required bool isLive, + required bool liveRetrying, + required int liveFallbackLevel, + required bool liveRetryFailed, +}) { + if (cause == PlayerError.serverHttp500 || fatalHttpStatuses.contains(500)) { + return PlaybackFailureAction.serverLimitDialog; + } + + if (!isLive && (cause == PlayerError.serverHttp404 || fatalHttpStatuses.contains(404))) { + return PlaybackFailureAction.mediaUnreadableDialog; + } + + if (isLive) { + if (liveRetrying) return PlaybackFailureAction.ignore; + if (liveFallbackLevel < maxLiveFallbackLevel) return PlaybackFailureAction.liveRetry; + if (liveRetryFailed) return PlaybackFailureAction.liveInterrupted; + } + + return PlaybackFailureAction.fatal; +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5698229d..81db9d3c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -89,6 +89,7 @@ import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_stream_retry.dart'; import 'video_player/live_timeline_report.dart'; import 'video_player/wakelock_controller.dart'; +import 'video_player/playback_failure_action.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; import 'video_player/tv_background_suspend_policy.dart'; diff --git a/test/screens/video_player/playback_failure_action_test.dart b/test/screens/video_player/playback_failure_action_test.dart new file mode 100644 index 00000000..94a9c36f --- /dev/null +++ b/test/screens/video_player/playback_failure_action_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mpv/models.dart'; +import 'package:plezy/screens/video_player/playback_failure_action.dart'; + +PlaybackFailureAction resolve({ + String? cause, + Set statuses = const {}, + bool isLive = false, + bool liveRetrying = false, + int liveFallbackLevel = 0, + bool liveRetryFailed = false, +}) { + return resolvePlaybackFailureAction( + cause: cause, + fatalHttpStatuses: statuses, + isLive: isLive, + liveRetrying: liveRetrying, + liveFallbackLevel: liveFallbackLevel, + liveRetryFailed: liveRetryFailed, + ); +} + +void main() { + group('HTTP 404', () { + test('on-demand playback treats it as terminal', () { + // The file behind the item is unreadable server-side (#1750); no retry, + // quality change, or backend switch recovers it. + expect(resolve(statuses: {404}), PlaybackFailureAction.mediaUnreadableDialog); + expect(resolve(cause: PlayerError.serverHttp404), PlaybackFailureAction.mediaUnreadableDialog); + }); + + test('live TV rides it out on the fallback ladder instead', () { + // An HLS segment that rolled off the playlist, or a transcode session + // restarting, answers 404 mid-stream. Showing "file unavailable" there + // would kill a recoverable stream. + expect(resolve(statuses: {404}, isLive: true), PlaybackFailureAction.liveRetry); + expect(resolve(cause: PlayerError.serverHttp404, isLive: true), PlaybackFailureAction.liveRetry); + }); + + test('live TV still exhausts the ladder before giving up', () { + expect( + resolve(statuses: {404}, isLive: true, liveFallbackLevel: maxLiveFallbackLevel, liveRetryFailed: true), + PlaybackFailureAction.liveInterrupted, + ); + }); + }); + + group('HTTP 500', () { + test('is terminal for on-demand and live alike', () { + // A bandwidth/transcoding limit rejection is not something a retry + // clears, so live TV gets the same modal. + expect(resolve(statuses: {500}), PlaybackFailureAction.serverLimitDialog); + expect(resolve(statuses: {500}, isLive: true), PlaybackFailureAction.serverLimitDialog); + expect(resolve(cause: PlayerError.serverHttp500, isLive: true), PlaybackFailureAction.serverLimitDialog); + }); + + test('outranks a 404 latched on the same open', () { + expect(resolve(statuses: {404, 500}), PlaybackFailureAction.serverLimitDialog); + }); + }); + + group('live fallback ladder', () { + test('climbs every rung below the bound', () { + for (var level = 0; level < maxLiveFallbackLevel; level++) { + expect(resolve(isLive: true, liveFallbackLevel: level), PlaybackFailureAction.liveRetry); + } + }); + + test('an in-flight retry owns the error', () { + expect(resolve(isLive: true, liveRetrying: true), PlaybackFailureAction.ignore); + // Even a latched 404 must not preempt the running retry. + expect(resolve(statuses: {404}, isLive: true, liveRetrying: true), PlaybackFailureAction.ignore); + }); + + test('a failed last retry reports an interruption', () { + expect( + resolve(isLive: true, liveFallbackLevel: maxLiveFallbackLevel, liveRetryFailed: true), + PlaybackFailureAction.liveInterrupted, + ); + }); + + test('an exhausted ladder that never failed falls through to the raw error', () { + expect(resolve(isLive: true, liveFallbackLevel: maxLiveFallbackLevel), PlaybackFailureAction.fatal); + }); + }); + + test('an error with no server status is fatal for on-demand playback', () { + expect(resolve(), PlaybackFailureAction.fatal); + expect(resolve(statuses: {503}), PlaybackFailureAction.fatal); + expect(resolve(cause: 'some-decoder-fault'), PlaybackFailureAction.fatal); + }); +}