fix(tv): live stream direct-stream fallback chain

This commit is contained in:
edde746
2026-04-07 07:58:42 +02:00
parent 603b5b5105
commit 6ac8c905c9
2 changed files with 69 additions and 2 deletions
+58
View File
@@ -185,6 +185,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isAtLiveEdge = true;
String? _transcodeSessionId;
/// Fallback level for live TV stream errors (mirrors Plex web client behavior).
/// 0 = directStream+directStreamAudio, 1 = no directStream, 2 = no DS + no DS audio.
int _liveStreamFallbackLevel = 0;
bool _isRetryingLiveStream = false;
// Auto-play next episode
Timer? _autoPlayTimer;
int _autoPlayCountdown = 5;
@@ -671,6 +676,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Listen to playback restart to detect first frame ready
_playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async {
_lastLogError = null;
_liveStreamFallbackLevel = 0;
if (!_hasFirstFrame.value) {
_hasFirstFrame.value = true;
Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'));
@@ -2101,6 +2107,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _onPlayerError(String error) {
appLogger.e('[Player ERROR] $error');
if (!mounted || _isExiting.value) return;
// Live TV: retry with progressively degraded stream settings
// (mirrors Plex web client fallback chain).
if (widget.isLive && _liveStreamFallbackLevel < 2 && !_isRetryingLiveStream) {
_liveStreamFallbackLevel++;
_isRetryingLiveStream = true;
appLogger.w('Live stream failed, retrying with fallback level $_liveStreamFallbackLevel');
_retryLiveStream().whenComplete(() => _isRetryingLiveStream = false);
return;
}
showGlobalErrorSnackBar(_lastLogError ?? error);
_handleBackButton();
}
@@ -2295,6 +2312,46 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
/// Retry the live stream with degraded direct-stream settings.
/// Level 1: disable video direct stream. Level 2: also disable audio direct stream.
Future<void> _retryLiveStream() async {
final client = widget.liveClient;
if (client == null || _liveSessionPath == null || _liveSessionIdentifier == null || _transcodeSessionId == null) {
appLogger.w('Cannot retry live stream — missing session info');
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
_handleBackButton();
return;
}
final ds = _liveStreamFallbackLevel < 1;
final dsa = _liveStreamFallbackLevel < 2;
appLogger.i('Retrying live stream: directStream=$ds directStreamAudio=$dsa');
// New transcode session so the server doesn't reuse the failed one.
_transcodeSessionId = PlexClient.generateSessionIdentifier();
final streamPath = await client.buildLiveStreamPath(
sessionPath: _liveSessionPath!,
sessionIdentifier: _liveSessionIdentifier!,
transcodeSessionId: _transcodeSessionId!,
directStream: ds,
directStreamAudio: dsa,
);
if (streamPath == null || !mounted) {
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
_handleBackButton();
return;
}
final streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token);
_livePlaybackStartTime = DateTime.now();
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
}
/// Force mpv to reconnect its HTTP stream by seeking to the current position.
/// This bypasses ffmpeg's exponential reconnect backoff when the app detects
/// that network connectivity has been restored.
@@ -2418,6 +2475,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (tuneResult == null || !mounted) return;
_transcodeSessionId = PlexClient.generateSessionIdentifier();
_liveStreamFallbackLevel = 0;
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
+11 -2
View File
@@ -2692,6 +2692,8 @@ class PlexClient {
required String sessionIdentifier,
required String transcodeSessionId,
int? offsetSeconds,
bool directStream = true,
bool directStreamAudio = true,
}) async {
try {
final allParams = <String, String>{
@@ -2702,13 +2704,13 @@ class PlexClient {
'protocol': 'http',
'fastSeek': '1',
'directPlay': '0',
'directStream': '1',
'directStream': directStream ? '1' : '0',
'subtitleSize': '100',
'audioBoost': '100',
'location': 'lan',
'addDebugOverlay': '0',
'autoAdjustQuality': '0',
'directStreamAudio': '1',
'directStreamAudio': directStreamAudio ? '1' : '0',
'advancedSubtitles': 'text',
'mediaBufferSize': '157286',
'session': transcodeSessionId,
@@ -2748,6 +2750,13 @@ class PlexClient {
return null;
}
// Log decision response for diagnostics (the web client parses this XML
// to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode).
final decisionBody = decisionResponse.data?.toString() ?? '';
if (decisionBody.isNotEmpty) {
appLogger.d('Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}');
}
// Token is added by the caller via .withPlexToken()
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries