diff --git a/lib/screens/video_player/live_stream_retry.dart b/lib/screens/video_player/live_stream_retry.dart new file mode 100644 index 00000000..dd05252c --- /dev/null +++ b/lib/screens/video_player/live_stream_retry.dart @@ -0,0 +1,45 @@ +typedef LiveStreamRecovery = Future Function(); +typedef LiveStreamUrlLookup = Future Function(Session session); + +enum LiveStreamRetryResult { succeeded, failed, stale } + +/// Runs one live-stream recovery attempt while the caller remains current. +/// +/// Every asynchronous stage is followed by a current-attempt check. Failures +/// from stale attempts are intentionally ignored because a newer operation +/// owns the player and its error UI. +Future runLiveStreamRetry({ + required LiveStreamRecovery recover, + required LiveStreamUrlLookup lookupStreamUrl, + required Future Function() applyPlayerOptions, + required Future Function(String streamUrl) open, + required bool Function() isCurrent, + required void Function(Session session) adoptSession, + required void Function(Object error, StackTrace stackTrace) reportFailure, + required void Function() onFinished, +}) async { + try { + final recovered = await recover(); + if (!isCurrent()) return LiveStreamRetryResult.stale; + if (recovered == null) throw StateError('Live stream recovery returned no session'); + + final streamUrl = await lookupStreamUrl(recovered); + if (!isCurrent()) return LiveStreamRetryResult.stale; + if (streamUrl == null) throw StateError('Live stream recovery returned no URL'); + + await applyPlayerOptions(); + if (!isCurrent()) return LiveStreamRetryResult.stale; + + await open(streamUrl); + if (!isCurrent()) return LiveStreamRetryResult.stale; + + adoptSession(recovered); + return LiveStreamRetryResult.succeeded; + } catch (error, stackTrace) { + if (!isCurrent()) return LiveStreamRetryResult.stale; + reportFailure(error, stackTrace); + return LiveStreamRetryResult.failed; + } finally { + onFinished(); + } +} diff --git a/lib/screens/video_player/live_tv_session_state.dart b/lib/screens/video_player/live_tv_session_state.dart index cdeaf392..b7e92f23 100644 --- a/lib/screens/video_player/live_tv_session_state.dart +++ b/lib/screens/video_player/live_tv_session_state.dart @@ -43,6 +43,7 @@ class LiveTvSessionState { /// 2 = no DS + no DS audio. int fallbackLevel = 0; bool retrying = false; + bool retryFailed = false; /// Whether the timeline heartbeat should restart when the app resumes /// from the background (it is suspended on hide). diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index 7292ae68..e2a5d63c 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -22,12 +22,21 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { // Live TV: retry with progressively degraded stream settings // (mirrors Plex web client fallback chain). - if (widget.isLive && _live.fallbackLevel < 2 && !_live.retrying) { - _live.fallbackLevel++; - _live.retrying = true; - appLogger.w('Live stream failed, retrying with fallback level $_live.fallbackLevel'); - _retryLiveStream().whenComplete(() => _live.retrying = false); - return; + if (widget.isLive) { + // 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) { + _live.fallbackLevel++; + _live.retrying = true; + appLogger.w('Live stream failed, retrying with fallback level $_live.fallbackLevel'); + unawaited(_retryLiveStream()); + return; + } + if (_live.retryFailed) { + showGlobalErrorSnackBar(t.messages.streamInterrupted); + return; + } } showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 8bf7a62f..7a9ec740 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -97,8 +97,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _liveSeek.cancel(); final currentPlayer = player; if (!mounted || currentPlayer == null) return; + final generation = _playbackGeneration; + bool isCurrent() => _isCurrentPlaybackGeneration(generation, currentPlayer); final session = _live.session; if (session == null) { + _live.retrying = false; appLogger.w('Cannot retry live stream — no session'); showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); unawaited(_handleBackButton()); @@ -109,21 +112,29 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { final dsa = _live.fallbackLevel < 2; appLogger.i('Retrying live stream: directStream=$ds directStreamAudio=$dsa'); - final recovered = await session.recover(directStream: ds, directStreamAudio: dsa); - if (!mounted || player != currentPlayer) return; - final streamUrl = recovered == null ? null : await recovered.streamUrlAt(); - if (!mounted || player != currentPlayer) return; - if (recovered == null || streamUrl == null) { - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); - unawaited(_handleBackButton()); - return; + final result = await runLiveStreamRetry( + recover: () => session.recover(directStream: ds, directStreamAudio: dsa), + lookupStreamUrl: (recovered) => recovered.streamUrlAt(), + applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer), + open: (streamUrl) => + currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true), + isCurrent: isCurrent, + adoptSession: (recovered) { + _live.adoptSession(recovered); + _live.markStreamRestartedAtLiveEdge(); + }, + reportFailure: (error, stackTrace) { + appLogger.e('Failed to recover live stream', error: error, stackTrace: stackTrace); + _live.retryFailed = true; + showGlobalErrorSnackBar(t.messages.streamInterrupted); + }, + onFinished: () { + if (isCurrent()) _live.retrying = false; + }, + ); + if (result == LiveStreamRetryResult.succeeded && isCurrent()) { + _live.retryFailed = false; } - - _live.adoptSession(recovered); - _live.markStreamRestartedAtLiveEdge(); - - await _setLiveStreamOptions(currentPlayer); - await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); } /// Configure MPV options for live streaming. diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 97349024..1ca1eb90 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -139,6 +139,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _lastLogError = null; _sawServer500 = false; _live.fallbackLevel = 0; + _live.retryFailed = false; final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService); _trackManager?.onPlaybackRestart(); await markFirstFrameReady; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6bf43364..72dac1fd 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -79,6 +79,7 @@ import '../utils/stream_buffer_sizing.dart'; import '../utils/video_player_navigation.dart'; import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; +import 'video_player/live_stream_retry.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'; @@ -545,6 +546,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _playWithPlaybackIntent(Player currentPlayer) { _playbackIntentShouldPlay = true; + if (widget.isLive && _live.retryFailed) { + if (_live.retrying) return Future.value(); + _live.retrying = true; + return _retryLiveStream(); + } if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) { // Parked on a dead stream: play/pause on a drained cache is a no-op // (mpv doesn't even flip `pause` on EOF), so any press means "get my @@ -560,6 +566,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin } Future _playOrPauseWithPlaybackIntent(Player currentPlayer) { + if (widget.isLive && _live.retryFailed) { + return _playWithPlaybackIntent(currentPlayer); + } if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) { _playbackIntentShouldPlay = true; return _retrySpuriousEofRecovery(reason: 'play/pause pressed'); diff --git a/test/screens/video_player/live_stream_retry_test.dart b/test/screens/video_player/live_stream_retry_test.dart new file mode 100644 index 00000000..447616a0 --- /dev/null +++ b/test/screens/video_player/live_stream_retry_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/video_player/live_stream_retry.dart'; + +void main() { + group('runLiveStreamRetry', () { + test('reports recover failures and finishes retry state', () async { + final harness = _RetryHarness(failAt: _Stage.recover); + + expect(await harness.run(), LiveStreamRetryResult.failed); + + expect(harness.failures, hasLength(1)); + expect(harness.finished, isTrue); + expect(harness.adopted, isFalse); + }); + + test('reports stream URL lookup failures and finishes retry state', () async { + final harness = _RetryHarness(failAt: _Stage.lookup); + + expect(await harness.run(), LiveStreamRetryResult.failed); + + expect(harness.failures, hasLength(1)); + expect(harness.finished, isTrue); + expect(harness.adopted, isFalse); + }); + + test('reports player option failures and finishes retry state', () async { + final harness = _RetryHarness(failAt: _Stage.options); + + expect(await harness.run(), LiveStreamRetryResult.failed); + + expect(harness.failures, hasLength(1)); + expect(harness.finished, isTrue); + expect(harness.adopted, isFalse); + }); + + test('reports player open failures and finishes retry state', () async { + final harness = _RetryHarness(failAt: _Stage.open); + + expect(await harness.run(), LiveStreamRetryResult.failed); + + expect(harness.failures, hasLength(1)); + expect(harness.finished, isTrue); + expect(harness.adopted, isFalse); + }); + + test('adopts the recovered session after a successful open', () async { + final harness = _RetryHarness(); + + expect(await harness.run(), LiveStreamRetryResult.succeeded); + + expect(harness.calls, [_Stage.recover, _Stage.lookup, _Stage.options, _Stage.open]); + expect(harness.failures, isEmpty); + expect(harness.finished, isTrue); + expect(harness.adopted, isTrue); + }); + + test('stale operation does not report an async failure', () async { + final harness = _RetryHarness(failAt: _Stage.lookup); + harness.becomeStaleAt = _Stage.lookup; + + expect(await harness.run(), LiveStreamRetryResult.stale); + + expect(harness.failures, isEmpty); + expect(harness.finished, isTrue); + expect(harness.adopted, isFalse); + }); + }); +} + +enum _Stage { recover, lookup, options, open } + +class _RetryHarness { + _RetryHarness({this.failAt}); + + final _Stage? failAt; + _Stage? becomeStaleAt; + final calls = <_Stage>[]; + final failures = []; + bool current = true; + bool finished = false; + bool adopted = false; + + Future run() => runLiveStreamRetry( + recover: () => _stage(_Stage.recover, Object.new), + lookupStreamUrl: (_) => _stage(_Stage.lookup, () => 'https://example.com/live'), + applyPlayerOptions: () => _stage(_Stage.options, () {}), + open: (_) => _stage(_Stage.open, () {}), + isCurrent: () => current, + adoptSession: (_) => adopted = true, + reportFailure: (error, _) => failures.add(error), + onFinished: () => finished = true, + ); + + Future _stage(_Stage stage, T Function() value) async { + calls.add(stage); + if (becomeStaleAt == stage) current = false; + if (failAt == stage) throw StateError('$stage failed'); + return value(); + } +}