fix(player): keep live TV on its retry ladder when a stream 404s
The 404 branch added in 16668be5 ran ahead of the live-TV fallback chain, so a transient live 404 — an HLS segment rolled off the playlist, or a transcode session restarting under us — showed "file unavailable" and killed a stream the bounded ladder would have recovered. Only on-demand playback can read a 404 as terminal, where it really does mean the file is unreadable. 500 stays terminal for both, since a limit rejection is not something a retry clears. The dispatch lived in a private extension on the screen state, where no test could reach the decision. Extract it as resolvePlaybackFailureAction next to runLiveStreamRetry, which already sets that precedent, and cover both the live and on-demand paths plus the ladder's rungs.
This commit is contained in:
@@ -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 <redacted url>") 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) {
|
||||
|
||||
@@ -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<int> 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<int> statuses = const <int>{},
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user