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.
|
/// 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).
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user