From a1d78562d6cc3205a52ace2883590b2dca3dff26 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:52:51 +0100 Subject: [PATCH] fix(tv): playback --- lib/screens/video_player_screen.dart | 29 ++++- lib/services/plex_client.dart | 120 +++++++++++++++--- .../desktop_video_controls.dart | 88 +++++++------ .../sheets/video_settings_sheet.dart | 10 +- .../video_controls/video_controls.dart | 6 +- .../widgets/track_chapter_controls.dart | 5 + 6 files changed, 193 insertions(+), 65 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 1b413c37..98be3e79 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -704,6 +704,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Skip play queue in offline mode (requires server connection) if (widget.isOffline) return; + // Skip play queue for live TV (would interfere with tuner session) + if (widget.isLive) return; + // Only create play queues for episodes if (!widget.metadata.isEpisode) { return; @@ -839,12 +842,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { _hasFirstFrame.value = false; await player!.requestAudioFocus(); - - final client = widget.liveClient ?? _getClientForMetadata(context); - final plexHeaders = client.config.headers; + await _setLiveStreamOptions(); await player!.open( - Media(widget.liveStreamUrl!, headers: plexHeaders), + Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), play: true, ); @@ -1618,6 +1619,23 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isSwitchingChannel = false; /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) + /// Configure MPV/FFmpeg options for live streaming resilience. + /// Enables automatic reconnection on EOF and network errors. + Future _setLiveStreamOptions() async { + final p = player!; + // FFmpeg HTTP protocol reconnection + await p.setProperty('stream-lavf-o-append', 'reconnect=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_at_eof=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_streamed=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_on_network_error=1'); + await p.setProperty('stream-lavf-o-append', 'reconnect_delay_max=30'); + // Demuxer: retry up to 1000 times on stream reload failures + await p.setProperty('demuxer-lavf-o', 'max_reload=1000'); + // Re-open the stream URL when EOF is reached + await p.setProperty('loop-playlist', 'force'); + await p.setProperty('force-seekable', 'no'); + } + Future _switchLiveChannel(int delta) async { final channels = widget.liveChannels; if (channels == null || channels.isEmpty) return; @@ -1651,8 +1669,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin final streamUrl = '${client.config.baseUrl}${result.streamPath}'.withPlexToken(client.config.token); + await _setLiveStreamOptions(); await player!.open( - Media(streamUrl, headers: client.config.headers), + Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index da51e47e..92ea58cd 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:dio/dio.dart'; @@ -2038,31 +2039,120 @@ class PlexClient { ); } - /// Tune to a live TV channel. Returns metadata and the stream URL path. + /// Generate 24-char random alphanumeric string (matching official client format) + static String _generateSessionIdentifier() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final rand = Random(); + return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join(); + } + + /// Tune to a live TV channel and set up the transcode session. + /// + /// Flow: tune → decision → return /start path (MKV-over-HTTP). Future<({PlexMetadata metadata, String streamPath})?> tuneChannel(String dvrKey, String channelIdentifier) async { try { + final sessionIdentifier = _generateSessionIdentifier(); + final response = await _dio.post( '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', + queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier}, ); - final metadataJson = _getFirstMetadataJson(response); - if (metadataJson == null) return null; + + if (response.statusCode != null && response.statusCode! >= 400) { + appLogger.w('Tune channel returned status ${response.statusCode}'); + return null; + } + + final container = _getMediaContainer(response); + if (container == null) return null; + + // Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata + Map? metadataJson; + final subscriptions = container['MediaSubscription'] as List?; + if (subscriptions != null && subscriptions.isNotEmpty) { + final sub = subscriptions[0] as Map; + final ops = sub['MediaGrabOperation'] as List?; + if (ops != null && ops.isNotEmpty) { + final op = ops[0] as Map; + final nested = op['Metadata']; + if (nested is Map) { + metadataJson = nested; + } + } + } + metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map?; + + if (metadataJson == null) { + appLogger.w('Tune channel: no metadata in response'); + return null; + } final metadata = _createTaggedMetadata(metadataJson); - // Extract stream path from Media[0].Part[0].key - String? streamPath; - final mediaList = metadataJson['Media'] as List?; - if (mediaList != null && mediaList.isNotEmpty) { - final parts = (mediaList[0] as Map)['Part'] as List?; - if (parts != null && parts.isNotEmpty) { - streamPath = (parts[0] as Map)['key'] as String?; - } + final sessionPath = metadataJson['key'] as String?; + if (sessionPath == null) { + appLogger.w('Tune channel: no session path in metadata key'); + return null; } - if (streamPath == null) return null; - return (metadata: metadata, streamPath: streamPath); - } catch (e) { - appLogger.e('Failed to tune channel', error: e); + // All identity goes in query params; the only HTTP header is Accept-Language + // (matching the official Plex client behaviour). + final allParams = { + 'hasMDE': '1', + 'path': sessionPath, + 'mediaIndex': '0', + 'partIndex': '0', + 'protocol': 'http', + 'fastSeek': '1', + 'directPlay': '0', + 'directStream': '1', + 'subtitleSize': '100', + 'audioBoost': '100', + 'location': 'lan', + 'addDebugOverlay': '0', + 'autoAdjustQuality': '0', + 'directStreamAudio': '1', + 'advancedSubtitles': 'text', + 'mediaBufferSize': '157286', + 'session': _generateSessionIdentifier(), + 'subtitles': 'auto', + 'copyts': '0', + 'Accept-Language': 'en', + 'X-Plex-Session-Identifier': sessionIdentifier, + 'X-Plex-Chunked': '1', + 'X-Plex-Incomplete-Segments': '1', + 'X-Plex-Product': config.product, + 'X-Plex-Version': config.version, + 'X-Plex-Client-Identifier': config.clientIdentifier, + 'X-Plex-Platform': config.platform, + 'X-Plex-Client-Profile-Name': 'Plex Desktop', + if (config.token != null) 'X-Plex-Token': config.token!, + }; + + // Manual query encoding — Dio encodes spaces as '+' but Plex requires '%20'. + final queryString = allParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + // Decision — bare Dio so no default X-Plex-* HTTP headers leak through. + final decisionDio = Dio(BaseOptions(headers: {'Accept-Language': 'en'})); + final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; + final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl)); + + if (decisionResponse.statusCode != 200) { + appLogger.w('Decision returned ${decisionResponse.statusCode}'); + return null; + } + + // Token is added by the caller via .withPlexToken() + final startParams = Map.from(allParams)..remove('X-Plex-Token'); + final startQuery = startParams.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + return (metadata: metadata, streamPath: '/video/:/transcode/universal/start?$startQuery'); + } catch (e, st) { + appLogger.e('Failed to tune channel', error: e, stackTrace: st); return null; } } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 49423205..ec9de319 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -570,51 +570,54 @@ class DesktopVideoControlsState extends State { semanticLabel: t.videoControls.nextButton, ), ), - // Finish time (hidden when too narrow to fit) - Expanded( - child: StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, posSnap) { - return StreamBuilder( - stream: widget.player.streams.duration, - initialData: widget.player.state.duration, - builder: (context, durSnap) { - return StreamBuilder( - stream: widget.player.streams.rate, - initialData: widget.player.state.rate, - builder: (context, rateSnap) { - final position = posSnap.data ?? Duration.zero; - final duration = durSnap.data ?? Duration.zero; - final remaining = duration - position; - final rate = rateSnap.data ?? 1.0; - if (remaining.inSeconds <= 0) return const SizedBox.shrink(); + // Finish time (hidden for live TV and when too narrow to fit) + if (widget.isLive) + const Spacer() + else + Expanded( + child: StreamBuilder( + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, posSnap) { + return StreamBuilder( + stream: widget.player.streams.duration, + initialData: widget.player.state.duration, + builder: (context, durSnap) { + return StreamBuilder( + stream: widget.player.streams.rate, + initialData: widget.player.state.rate, + builder: (context, rateSnap) { + final position = posSnap.data ?? Duration.zero; + final duration = durSnap.data ?? Duration.zero; + final remaining = duration - position; + final rate = rateSnap.data ?? 1.0; + if (remaining.inSeconds <= 0) return const SizedBox.shrink(); - final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate)); - const style = TextStyle(color: Colors.white70, fontSize: 13); + final text = t.videoControls.endsAt(time: formatFinishTime(remaining, rate: rate)); + const style = TextStyle(color: Colors.white70, fontSize: 13); - return LayoutBuilder( - builder: (context, constraints) { - final tp = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - final textWidth = tp.width + 8; - tp.dispose(); - if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 8), - child: Text(text, style: style), - ); - }, - ); - }, - ); - }, - ); - }, + return LayoutBuilder( + builder: (context, constraints) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + final textWidth = tp.width + 8; + tp.dispose(); + if (textWidth > constraints.maxWidth) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(text, style: style), + ); + }, + ); + }, + ); + }, + ); + }, + ), ), - ), // Volume control VolumeControl( player: widget.player, @@ -652,6 +655,7 @@ class DesktopVideoControlsState extends State { onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, canControl: widget.canControl, + isLive: widget.isLive, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, ), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index d432ca37..a50d95a8 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -77,6 +77,9 @@ class VideoSettingsSheet extends StatefulWidget { /// Whether the user can control playback (false hides speed option in host-only mode). final bool canControl; + /// Whether this is a live TV stream (hides speed settings). + final bool isLive; + /// Optional shader service for MPV shader control final ShaderService? shaderService; @@ -89,6 +92,7 @@ class VideoSettingsSheet extends StatefulWidget { required this.audioSyncOffset, required this.subtitleSyncOffset, this.canControl = true, + this.isLive = false, this.shaderService, this.onShaderChanged, }); @@ -101,6 +105,7 @@ class VideoSettingsSheet extends StatefulWidget { VoidCallback? onOpen, VoidCallback? onClose, bool canControl = true, + bool isLive = false, ShaderService? shaderService, VoidCallback? onShaderChanged, }) { @@ -113,6 +118,7 @@ class VideoSettingsSheet extends StatefulWidget { audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset, canControl: canControl, + isLive: isLive, shaderService: shaderService, onShaderChanged: onShaderChanged, ), @@ -253,8 +259,8 @@ class _VideoSettingsSheetState extends State { return ListView( children: [ - // Playback Speed - only show if user can control playback - if (widget.canControl) + // Playback Speed - hidden for live TV and when user cannot control playback + if (widget.canControl && !widget.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 2f5e16a3..adf70a15 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -792,6 +792,9 @@ class _PlexVideoControlsState extends State with WindowListen } Future _loadPlaybackExtras() async { + // Live TV metadata uses EPG rating keys, not library items + if (widget.isLive) return; + try { appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}'); final client = _getClientForMetadata(); @@ -902,6 +905,7 @@ class _PlexVideoControlsState extends State with WindowListen onStartAutoHide: _startHideTimer, serverId: widget.metadata.serverId ?? '', canControl: widget.canControl, + isLive: widget.isLive, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, ); @@ -1167,7 +1171,7 @@ class _PlexVideoControlsState extends State with WindowListen /// Handle long-press start - activate 2x speed void _handleLongPressStart() { - if (!widget.canControl) return; // Respect Watch Together permissions + if (!widget.canControl || widget.isLive) return; setState(() { _isLongPressing = true; diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 6e26093b..ae21434c 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -60,6 +60,9 @@ class TrackChapterControls extends StatelessWidget { /// Whether the user can control playback (false in host-only mode for non-host). final bool canControl; + /// Whether this is a live TV stream (hides speed settings). + final bool isLive; + const TrackChapterControls({ super.key, required this.player, @@ -89,6 +92,7 @@ class TrackChapterControls extends StatelessWidget { this.onFocusChange, this.onNavigateLeft, this.canControl = true, + this.isLive = false, this.shaderService, this.onShaderChanged, }); @@ -194,6 +198,7 @@ class TrackChapterControls extends StatelessWidget { onOpen: onCancelAutoHide, onClose: onStartAutoHide, canControl: canControl, + isLive: isLive, shaderService: shaderService, onShaderChanged: onShaderChanged, );