diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 98be3e79..d0902f04 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -76,6 +76,8 @@ class VideoPlayerScreen extends StatefulWidget { final int? liveCurrentChannelIndex; final String? liveDvrKey; final PlexClient? liveClient; + final String? liveSessionIdentifier; + final String? liveSessionPath; const VideoPlayerScreen({ super.key, @@ -91,6 +93,8 @@ class VideoPlayerScreen extends StatefulWidget { this.liveCurrentChannelIndex, this.liveDvrKey, this.liveClient, + this.liveSessionIdentifier, + this.liveSessionPath, }); @override @@ -132,6 +136,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Live TV channel navigation int _liveChannelIndex = -1; String? _liveChannelName; + String? _liveSessionIdentifier; + String? _liveSessionPath; + Timer? _liveTimelineTimer; // Auto-play next episode Timer? _autoPlayTimer; @@ -195,6 +202,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Initialize live TV channel tracking _liveChannelIndex = widget.liveCurrentChannelIndex ?? -1; _liveChannelName = widget.liveChannelName; + _liveSessionIdentifier = widget.liveSessionIdentifier; + _liveSessionPath = widget.liveSessionPath; // Initialize Play Next dialog focus nodes _playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel'); @@ -592,8 +601,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _initializeServices() async { if (!mounted || player == null) return; - // Skip progress tracking for live TV - if (widget.isLive) return; + // Live TV: send timeline heartbeats to keep transcode session alive + if (widget.isLive) { + _startLiveTimelineUpdates(); + return; + } // Get client (null in offline mode) final client = widget.isOffline ? null : _getClientForMetadata(context); @@ -844,10 +856,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await player!.requestAudioFocus(); await _setLiveStreamOptions(); - await player!.open( - Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), - play: true, - ); + await player!.open(Media(widget.liveStreamUrl!, headers: const {'Accept-Language': 'en'}), play: true); if (mounted) { setState(() { @@ -1258,10 +1267,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Store provider reference for use in dispose and notify remote try { _companionRemoteProvider = context.read(); - _companionRemoteProvider!.sendCommand( - RemoteCommandType.syncState, - data: {'playerActive': true}, - ); + _companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true}); } catch (_) {} } @@ -1282,10 +1288,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _savedOnHome = null; // Notify remote that player is no longer active - _companionRemoteProvider?.sendCommand( - RemoteCommandType.syncState, - data: {'playerActive': false}, - ); + _companionRemoteProvider?.sendCommand(RemoteCommandType.syncState, data: {'playerActive': false}); _companionRemoteProvider = null; } @@ -1323,7 +1326,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _onAudioTrackChanged(next); if (mounted) { - final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + final label = + 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; showAppSnackBar(context, label, duration: const Duration(seconds: 1)); } } @@ -1418,6 +1422,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin _progressTracker?.sendProgress('stopped'); _progressTracker?.stopTracking(); _progressTracker?.dispose(); + _sendLiveTimeline('stopped'); + _stopLiveTimelineUpdates(); // Remove PiP state listener, clear callback, and dispose video filter manager _videoPIPManager?.isPipActive.removeListener(_onPipStateChanged); @@ -1619,6 +1625,45 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isSwitchingChannel = false; /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) + /// Start periodic timeline heartbeats for live TV transcode session. + void _startLiveTimelineUpdates() { + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _sendLiveTimeline('playing'); + }); + // Send initial heartbeat immediately + _sendLiveTimeline('playing'); + } + + void _stopLiveTimelineUpdates() { + _liveTimelineTimer?.cancel(); + _liveTimelineTimer = null; + } + + Future _sendLiveTimeline(String state) async { + final sessionId = _liveSessionIdentifier; + final sessionPath = _liveSessionPath; + if (sessionId == null || sessionPath == null) return; + + final client = widget.liveClient; + if (client == null) return; + + try { + final position = player?.state.position ?? Duration.zero; + final duration = player?.state.duration ?? Duration.zero; + await client.updateLiveTimeline( + ratingKey: widget.metadata.ratingKey, + sessionPath: sessionPath, + sessionIdentifier: sessionId, + state: state, + time: position.inMilliseconds, + duration: duration.inMilliseconds, + ); + } catch (e) { + appLogger.d('Live timeline update failed', error: e); + } + } + /// Configure MPV/FFmpeg options for live streaming resilience. /// Enables automatic reconnection on EOF and network errors. Future _setLiveStreamOptions() async { @@ -1631,8 +1676,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin 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'); } @@ -1655,9 +1698,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { // Look up the correct client/DVR for this channel's server final multiServer = context.read(); - final serverInfo = multiServer.liveTvServers.where( - (s) => s.serverId == channel.serverId, - ).firstOrNull ?? multiServer.liveTvServers.firstOrNull; + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? + multiServer.liveTvServers.firstOrNull; if (serverInfo == null) return; @@ -1670,15 +1713,17 @@ 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: const {'Accept-Language': 'en'}), - play: true, - ); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true); setState(() { _liveChannelIndex = newIndex; _liveChannelName = channel.displayName; + _liveSessionIdentifier = result.sessionIdentifier; + _liveSessionPath = result.sessionPath; }); + + // Restart timeline heartbeats for the new session + _startLiveTimelineUpdates(); } catch (e) { appLogger.e('Failed to switch channel', error: e); } finally { @@ -1692,10 +1737,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _liveChannelIndex >= 0 && _liveChannelIndex < (widget.liveChannels!.length - 1); - bool get _hasPreviousChannel => - widget.isLive && - widget.liveChannels != null && - _liveChannelIndex > 0; + bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0; void _startAutoPlayTimer() { _autoPlayTimer?.cancel(); @@ -2408,7 +2450,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ), const SizedBox(width: 8), - Text(t.watchTogether.reconnectingToHost, style: const TextStyle(color: Colors.white, fontSize: 12)), + Text( + t.watchTogether.reconnectingToHost, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), ], ), ), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 92ea58cd..ebcae405 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1130,6 +1130,29 @@ class PlexClient { ); } + /// Send a live TV timeline heartbeat to keep the transcode session alive. + Future updateLiveTimeline({ + required String ratingKey, + required String sessionPath, + required String sessionIdentifier, + required String state, + required int time, + required int duration, + }) async { + await _dio.post( + '/:/timeline', + queryParameters: { + 'ratingKey': ratingKey, + 'key': sessionPath, + 'state': state, + 'hasMDE': '1', + 'time': time, + 'duration': duration, + 'X-Plex-Session-Identifier': sessionIdentifier, + }, + ); + } + /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { @@ -2049,7 +2072,7 @@ class PlexClient { /// 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 { + Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(String dvrKey, String channelIdentifier) async { try { final sessionIdentifier = _generateSessionIdentifier(); @@ -2150,7 +2173,12 @@ class PlexClient { .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); - return (metadata: metadata, streamPath: '/video/:/transcode/universal/start?$startQuery'); + return ( + metadata: metadata, + streamPath: '/video/:/transcode/universal/start?$startQuery', + sessionIdentifier: sessionIdentifier, + sessionPath: sessionPath, + ); } catch (e, st) { appLogger.e('Failed to tune channel', error: e, stackTrace: st); return null; diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index ce90f556..f55d6114 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -56,6 +56,8 @@ Future navigateToLiveTv( ), liveDvrKey: dvrKey, liveClient: client, + liveSessionIdentifier: result.sessionIdentifier, + liveSessionPath: result.sessionPath, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero,