diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index b221d5e3..004fc096 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -185,6 +185,11 @@ class VideoPlayerScreenState extends State 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 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 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 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 _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 with WidgetsBindin if (tuneResult == null || !mounted) return; _transcodeSessionId = PlexClient.generateSessionIdentifier(); + _liveStreamFallbackLevel = 0; final streamPath = await client.buildLiveStreamPath( sessionPath: tuneResult.sessionPath, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 37eaa81c..e9ac10d4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2692,6 +2692,8 @@ class PlexClient { required String sessionIdentifier, required String transcodeSessionId, int? offsetSeconds, + bool directStream = true, + bool directStreamAudio = true, }) async { try { final allParams = { @@ -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.from(allParams)..remove('X-Plex-Token'); final startQuery = startParams.entries