fix(live-tv): contain retry failures

This commit is contained in:
edde746
2026-07-12 08:42:23 +02:00
parent a0f153e5ab
commit 69cf5fb8a1
7 changed files with 196 additions and 20 deletions
@@ -0,0 +1,45 @@
typedef LiveStreamRecovery<Session> = Future<Session?> Function();
typedef LiveStreamUrlLookup<Session> = Future<String?> 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<LiveStreamRetryResult> runLiveStreamRetry<Session>({
required LiveStreamRecovery<Session> recover,
required LiveStreamUrlLookup<Session> lookupStreamUrl,
required Future<void> Function() applyPlayerOptions,
required Future<void> 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();
}
}
@@ -43,6 +43,7 @@ class LiveTvSessionState {
/// 2 = no DS + no DS audio. /// 2 = no DS + no DS audio.
int fallbackLevel = 0; int fallbackLevel = 0;
bool retrying = false; bool retrying = false;
bool retryFailed = false;
/// Whether the timeline heartbeat should restart when the app resumes /// Whether the timeline heartbeat should restart when the app resumes
/// from the background (it is suspended on hide). /// from the background (it is suspended on hide).
+15 -6
View File
@@ -22,12 +22,21 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
// Live TV: retry with progressively degraded stream settings // Live TV: retry with progressively degraded stream settings
// (mirrors Plex web client fallback chain). // (mirrors Plex web client fallback chain).
if (widget.isLive && _live.fallbackLevel < 2 && !_live.retrying) { if (widget.isLive) {
_live.fallbackLevel++; // The bounded retry operation owns errors raised while applying/opening
_live.retrying = true; // its replacement stream. Do not let the same error close the route.
appLogger.w('Live stream failed, retrying with fallback level $_live.fallbackLevel'); if (_live.retrying) return;
_retryLiveStream().whenComplete(() => _live.retrying = false); if (_live.fallbackLevel < 2) {
return; _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)); showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message));
+25 -14
View File
@@ -97,8 +97,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
_liveSeek.cancel(); _liveSeek.cancel();
final currentPlayer = player; final currentPlayer = player;
if (!mounted || currentPlayer == null) return; if (!mounted || currentPlayer == null) return;
final generation = _playbackGeneration;
bool isCurrent() => _isCurrentPlaybackGeneration(generation, currentPlayer);
final session = _live.session; final session = _live.session;
if (session == null) { if (session == null) {
_live.retrying = false;
appLogger.w('Cannot retry live stream — no session'); appLogger.w('Cannot retry live stream — no session');
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
unawaited(_handleBackButton()); unawaited(_handleBackButton());
@@ -109,21 +112,29 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
final dsa = _live.fallbackLevel < 2; final dsa = _live.fallbackLevel < 2;
appLogger.i('Retrying live stream: directStream=$ds directStreamAudio=$dsa'); appLogger.i('Retrying live stream: directStream=$ds directStreamAudio=$dsa');
final recovered = await session.recover(directStream: ds, directStreamAudio: dsa); final result = await runLiveStreamRetry<LiveTvPlaybackSession>(
if (!mounted || player != currentPlayer) return; recover: () => session.recover(directStream: ds, directStreamAudio: dsa),
final streamUrl = recovered == null ? null : await recovered.streamUrlAt(); lookupStreamUrl: (recovered) => recovered.streamUrlAt(),
if (!mounted || player != currentPlayer) return; applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer),
if (recovered == null || streamUrl == null) { open: (streamUrl) =>
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true),
unawaited(_handleBackButton()); isCurrent: isCurrent,
return; 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. /// Configure MPV options for live streaming.
@@ -139,6 +139,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
_lastLogError = null; _lastLogError = null;
_sawServer500 = false; _sawServer500 = false;
_live.fallbackLevel = 0; _live.fallbackLevel = 0;
_live.retryFailed = false;
final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService); final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService);
_trackManager?.onPlaybackRestart(); _trackManager?.onPlaybackRestart();
await markFirstFrameReady; await markFirstFrameReady;
+9
View File
@@ -79,6 +79,7 @@ import '../utils/stream_buffer_sizing.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import 'video_player/completion_latch.dart'; import 'video_player/completion_latch.dart';
import 'video_player/frame_rate_matcher.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_args.dart';
import 'video_player/live_tv_session_state.dart'; import 'video_player/live_tv_session_state.dart';
import 'video_player/tv_background_suspend_policy.dart'; import 'video_player/tv_background_suspend_policy.dart';
@@ -545,6 +546,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> _playWithPlaybackIntent(Player currentPlayer) { Future<void> _playWithPlaybackIntent(Player currentPlayer) {
_playbackIntentShouldPlay = true; _playbackIntentShouldPlay = true;
if (widget.isLive && _live.retryFailed) {
if (_live.retrying) return Future.value();
_live.retrying = true;
return _retryLiveStream();
}
if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) { if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) {
// Parked on a dead stream: play/pause on a drained cache is a no-op // 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 // (mpv doesn't even flip `pause` on EOF), so any press means "get my
@@ -560,6 +566,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
Future<void> _playOrPauseWithPlaybackIntent(Player currentPlayer) { Future<void> _playOrPauseWithPlaybackIntent(Player currentPlayer) {
if (widget.isLive && _live.retryFailed) {
return _playWithPlaybackIntent(currentPlayer);
}
if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) { if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) {
_playbackIntentShouldPlay = true; _playbackIntentShouldPlay = true;
return _retrySpuriousEofRecovery(reason: 'play/pause pressed'); return _retrySpuriousEofRecovery(reason: 'play/pause pressed');
@@ -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 = <Object>[];
bool current = true;
bool finished = false;
bool adopted = false;
Future<LiveStreamRetryResult> run() => runLiveStreamRetry<Object>(
recover: () => _stage(_Stage.recover, Object.new),
lookupStreamUrl: (_) => _stage(_Stage.lookup, () => 'https://example.com/live'),
applyPlayerOptions: () => _stage<void>(_Stage.options, () {}),
open: (_) => _stage<void>(_Stage.open, () {}),
isCurrent: () => current,
adoptSession: (_) => adopted = true,
reportFailure: (error, _) => failures.add(error),
onFinished: () => finished = true,
);
Future<T> _stage<T>(_Stage stage, T Function() value) async {
calls.add(stage);
if (becomeStaleAt == stage) current = false;
if (failAt == stage) throw StateError('$stage failed');
return value();
}
}