Files
plezy/lib/screens/video_player/parts/errors.dart
T
edde746 8a46df2850 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.
2026-07-31 21:45:33 +02:00

96 lines
3.6 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
String _safePlaybackErrorMessage(Object error) {
final raw = error.toString();
final redacted = LogRedactionManager.redact(raw);
if (raw.contains('No client registered')) {
return t.messages.errorLoading(error: 'Server is unavailable for the active profile');
}
return t.messages.errorLoading(error: redacted);
}
void _onPlayerError(PlayerError err) {
appLogger.e('[Player ERROR] ${err.message}');
if (!mounted || _isExiting.value) return;
// A sidecar subtitle fetch can also log a status, but it never raises the
// 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,
);
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.
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());
case PlaybackFailureAction.liveInterrupted:
showGlobalErrorSnackBar(t.messages.liveStreamInterrupted);
case PlaybackFailureAction.fatal:
_hasFatalPlaybackError = true;
_progressTracker?.stopTracking();
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message));
unawaited(_handleBackButton());
}
}
void _onPlayerLog(PlayerLog log) {
final status = PlayerError.httpStatusFromLog(log.text);
if (status != null && fatalPlaybackHttpStatuses.contains(status)) _fatalHttpStatuses.add(status);
if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) {
appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}');
_lastLogError = _redactPlayerError(log.text.trim());
}
}
String _redactPlayerError(String message) => LogRedactionManager.redact(message);
Future<void> _showServerLimitDialog() async {
if (!mounted) return;
await showServerLimitDialog(context);
if (mounted) unawaited(_handleBackButton());
}
Future<void> _showMediaUnreadableDialog() async {
if (!mounted) return;
await showMediaUnreadableDialog(context);
if (mounted) unawaited(_handleBackButton());
}
/// Handle notification when native player switched from ExoPlayer to MPV
Future<void> _onBackendSwitched() async {
_playerBackendLabel = 'mpv';
_recordLifecycleState('backend_switched', action: 'mpv_fallback');
_toastController.show(
Symbols.swap_horiz_rounded,
t.messages.switchingToCompatiblePlayer,
duration: const Duration(seconds: 2),
);
await _trackManager?.onBackendSwitched();
}
}