fix(live-tv): contain retry failures
This commit is contained in:
@@ -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.
|
||||
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).
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<LiveTvPlaybackSession>(
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
Future<void> _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<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
Future<void> _playOrPauseWithPlaybackIntent(Player currentPlayer) {
|
||||
if (widget.isLive && _live.retryFailed) {
|
||||
return _playWithPlaybackIntent(currentPlayer);
|
||||
}
|
||||
if (_spuriousEofRecoveryParked && _playbackTransition == _PlaybackTransition.idle) {
|
||||
_playbackIntentShouldPlay = true;
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user