From dcfe0f8308a1c3d73fbe3b668ac0aa9ab5e84274 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:15:20 +0200 Subject: [PATCH] refactor(player): live playback drives LiveTvPlaybackSession --- lib/media/media_server_client.dart | 8 + lib/screens/livetv/live_tv_actions_mixin.dart | 9 +- lib/screens/livetv/tabs/guide_tab.dart | 2 +- .../video_player/live_tv_session_args.dart | 47 +-- .../video_player/live_tv_session_state.dart | 57 ++-- lib/screens/video_player/parts/lifecycle.dart | 2 +- lib/screens/video_player/parts/live_tv.dart | 290 +++++------------- .../video_player/parts/playback_start.dart | 120 +++----- lib/screens/video_player_screen.dart | 6 +- lib/utils/live_tv_player_navigation.dart | 108 ++----- 10 files changed, 204 insertions(+), 445 deletions(-) diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 294858f6..d3983a4e 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -589,6 +589,14 @@ abstract class MediaServerClient { /// media version's part path; Jellyfin returns its `/Videos/{id}/stream` /// endpoint with `Static=true` so transcoding is bypassed. Returns null /// when the backend can't resolve a playable URL for the item. + /// + /// Deliberately separate from the in-app playback funnel + /// (`PlaybackSourceResolver`): external players can't send custom headers, + /// so the URL must be self-contained (token in the query string), and + /// there's no session/transcode negotiation to carry. Likewise their + /// progress reporting is a one-shot started/stopped pair in + /// `ExternalPlayerService` — an external app exposes no live position + /// stream for the in-player tracker to follow. Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}); } diff --git a/lib/screens/livetv/live_tv_actions_mixin.dart b/lib/screens/livetv/live_tv_actions_mixin.dart index 5218bd53..a9edfd00 100644 --- a/lib/screens/livetv/live_tv_actions_mixin.dart +++ b/lib/screens/livetv/live_tv_actions_mixin.dart @@ -34,13 +34,12 @@ mixin LiveTvActionsMixin on State { /// /// Both backends route through the live-TV navigator so the player /// inherits the live-only branches (no Trakt scrobble, no progress - /// scrobble, channel up/down nav, no resume bookmark). Plex passes a - /// `client + dvrKey` and tunes inside the player; Jellyfin pre-resolves - /// the channel's `/Videos/{id}/stream` URL and lets the engine play it - /// directly. + /// scrobble, channel up/down nav, no resume bookmark). The player starts + /// the backend-neutral session itself (Plex tune / Jellyfin stream + /// negotiation under its loading spinner). Future tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: liveTvChannels); + await navigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: liveTvChannels); } /// Open the program-details bottom sheet. The poster is resolved from diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 69e4dc8a..8add868e 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -555,7 +555,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels); + await navigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels); } void _activateProgram(LiveTvChannel channel, LiveTvProgram program) { diff --git a/lib/screens/video_player/live_tv_session_args.dart b/lib/screens/video_player/live_tv_session_args.dart index e4018357..23dba495 100644 --- a/lib/screens/video_player/live_tv_session_args.dart +++ b/lib/screens/video_player/live_tv_session_args.dart @@ -1,41 +1,22 @@ -import '../../media/media_server_client.dart'; import '../../models/livetv_channel.dart'; -/// Launch parameters for a live TV session. A [VideoPlayerScreen] plays live -/// TV iff it was constructed with one of these — the type encodes the -/// "these fields travel together" invariant the nine separate nullable -/// constructor parameters used to leave implicit. +/// Launch parameters for a live TV session — pure UX data. A +/// [VideoPlayerScreen] plays live TV iff it was constructed with one of +/// these. +/// +/// Transport (tune/stream-URL resolution, session identity) is no longer +/// passed in: the player starts a backend-neutral `LiveTvPlaybackSession` +/// via `client.liveTv.startPlayback` itself, for both backends, so launch +/// and channel zapping share one resolution path and one spinner UX. class LiveTvSessionArgs { - final String? channelName; - - /// Pre-resolved stream URL (Jellyfin always provides one; Plex tunes - /// in-player when null). - final String? streamUrl; + /// The channel to start on. + final LiveTvChannel channel; + /// Full channel list for channel up/down navigation. final List? channels; + + /// Index of [channel] within [channels] (-1 / null when unknown). final int? currentChannelIndex; - final String? dvrKey; - /// Backend-neutral client typing. The four in-player live ops branch on - /// `client is PlexClient` / `client is JellyfinClient` at their use sites: - /// Plex tunes a transcode session and gets capture-buffer updates; - /// Jellyfin uses its `/Sessions/Playing*` endpoints for progress reporting - /// and re-opens [streamUrl] for retry. Tune (Plex-only by protocol) - /// and seek (Plex-only — Jellyfin live channels aren't seekable) gate - /// explicitly on `client is PlexClient`. - final MediaServerClient? client; - - final String? sessionIdentifier; - final String? sessionPath; - - const LiveTvSessionArgs({ - this.channelName, - this.streamUrl, - this.channels, - this.currentChannelIndex, - this.dvrKey, - this.client, - this.sessionIdentifier, - this.sessionPath, - }); + const LiveTvSessionArgs({required this.channel, this.channels, this.currentChannelIndex}); } diff --git a/lib/screens/video_player/live_tv_session_state.dart b/lib/screens/video_player/live_tv_session_state.dart index 6f9e6aff..da86de0b 100644 --- a/lib/screens/video_player/live_tv_session_state.dart +++ b/lib/screens/video_player/live_tv_session_state.dart @@ -1,59 +1,42 @@ import 'dart:async'; -import '../../media/media_server_client.dart'; +import '../../media/live_tv_support.dart'; import '../../models/livetv_capture_buffer.dart'; -import '../../services/jellyfin_client.dart'; -import '../../services/live_session_tracker.dart'; import 'live_tv_session_args.dart'; -/// Mutable state for one live TV playback session: tune/session identity, -/// the timeline heartbeat machinery, the capture buffer used for -/// time-shifting, and the retry/fallback ladder. +/// Mutable runtime state for one live TV playback: the current +/// [LiveTvPlaybackSession] protocol handle, the timeline heartbeat +/// machinery, the capture buffer used for time-shifting, and the +/// retry/fallback ladder. /// /// One instance lives on the player screen (inert when the screen plays /// VOD); the live-TV part file owns all the logic and reads/writes through /// this object so the session state has a single boundary and lifetime. +/// Protocol state (tune outputs, stream URLs, per-backend reporting) lives +/// on [session] — adopting a new session via [adoptSession] is the single +/// point where a (re)tune's outputs become current. class LiveTvSessionState { - LiveTvSessionState(LiveTvSessionArgs? args, {required this.itemId}) + LiveTvSessionState(LiveTvSessionArgs? args) : channelIndex = args?.currentChannelIndex ?? -1, - channelName = args?.channelName, - client = args?.client, - dvrKey = args?.dvrKey, - streamUrl = args?.streamUrl, - sessionIdentifier = args?.sessionIdentifier, - sessionPath = args?.sessionPath, - jellyfin = args?.client is JellyfinClient && args?.sessionIdentifier != null - ? JellyfinLiveSessionTracker(playSessionId: args?.sessionIdentifier) - : JellyfinLiveSessionTracker(); + channelName = args?.channel.displayName; int channelIndex; String? channelName; - MediaServerClient? client; - String? dvrKey; - String? streamUrl; - /// The channel/program item progress reports are attributed to; updated - /// on channel switches. - String itemId; + /// Backend-neutral protocol handle for the playing channel. Null until + /// the first `startPlayback` lands. + LiveTvPlaybackSession? session; - String? sessionIdentifier; - String? sessionPath; Timer? timelineTimer; int timelineGeneration = 0; DateTime? playbackStartTime; - String? programId; - int? durationMs; - - /// Jellyfin live TV heartbeat state machine. The Plex live branch keeps - /// its bespoke capture-buffer flow inline; this tracker only collapses - /// the Jellyfin started/progress/stopped transition. - JellyfinLiveSessionTracker jellyfin; + /// Current seekable window. Seeded from [session] on adoption, then + /// refreshed by timeline heartbeat responses. CaptureBuffer? captureBuffer; - int? programBeginsAt; + double streamStartEpoch = 0; bool atLiveEdge = true; - String? transcodeSessionId; /// Fallback level for live TV stream errors (mirrors Plex web client /// behavior). 0 = directStream+directStreamAudio, 1 = no directStream, @@ -65,6 +48,14 @@ class LiveTvSessionState { /// from the background (it is suspended on hide). bool resumeTimelineOnResume = false; + /// Make [newSession] current and seed the seekable window from its tune + /// snapshot. Every flow that produces a session (start, retry, channel + /// zap) adopts it here, so a field can't be forgotten in one copy. + void adoptSession(LiveTvPlaybackSession newSession) { + session = newSession; + captureBuffer = newSession.captureBuffer; + } + /// The stream just (re)started at the live edge — align the epoch /// bookkeeping every restart flow shares (retry, channel zap). void markStreamRestartedAtLiveEdge() { diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index e5c6fae1..080caa75 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -66,7 +66,7 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { void _resumeLiveTimelineAfterBackgroundIfNeeded() { final shouldResume = _live.resumeTimelineOnResume; _live.resumeTimelineOnResume = false; - if (shouldResume && _live.sessionIdentifier != null) { + if (shouldResume && _live.session != null) { _startLiveTimelineUpdates(); } } diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 91c60ad8..9cccc3c1 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -28,138 +28,89 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { } Future _sendLiveTimeline(String state) async { - final client = _live.client; + final session = _live.session; + if (session == null) return; + // For live TV, player position/duration are unreliable (often 0). Use + // elapsed wall-clock as the position and the program duration from tune + // metadata; the per-backend session owns the wire mapping. final playbackTime = _live.playbackStartTime != null ? DateTime.now().difference(_live.playbackStartTime!).inMilliseconds : 0; - if (client is PlexClient) { - final sessionId = _live.sessionIdentifier; - final sessionPath = _live.sessionPath; - if (sessionId == null || sessionPath == null) return; - try { - // Use the program ratingKey from tune metadata, not the channel key - final ratingKey = _live.programId ?? _live.itemId; - // For live TV, player position/duration are unreliable (often 0). - // Use playbackTime as time, and program duration from tune metadata. - // Plex rejects timeline pings where time > duration; grow duration to - // match — otherwise Tunarr-style short synthetic programs 400 mid-stream. - final time = playbackTime; - final duration = max(_live.durationMs ?? 0, time); - final updatedBuffer = await client.updateLiveTimeline( - ratingKey: ratingKey, - sessionPath: sessionPath, - sessionIdentifier: sessionId, - state: state, - time: time, - duration: duration, - playbackTime: playbackTime, - ); - if (updatedBuffer != null && mounted) { - _setPlayerState(() { - _live.captureBuffer = updatedBuffer; - _live.atLiveEdge = - (_currentPositionEpoch >= - updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); - }); - } - } catch (e) { - appLogger.d('Plex live timeline update failed', error: e); - } - return; - } - - if (client is JellyfinClient) { - await _live.jellyfin.report( - client: client, - itemId: _live.itemId, + try { + final updatedBuffer = await session.reportTimeline( state: state, - position: Duration(milliseconds: playbackTime), - duration: Duration(milliseconds: _live.durationMs ?? 0), + positionMs: playbackTime, + durationMs: session.program.durationMs ?? 0, ); - return; + if (updatedBuffer != null && mounted) { + _setPlayerState(() { + _live.captureBuffer = updatedBuffer; + _live.atLiveEdge = + (_currentPositionEpoch >= + updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); + }); + } + } catch (e) { + appLogger.d('Live timeline update failed', error: e); } } + /// Resolve the owning live-TV server for [channel] and start a playback + /// session on it — the shared resolution path for initial launch and + /// channel zapping (Plex tunes a DVR, Jellyfin negotiates a direct URL). + Future _startLiveSession(LiveTvChannel channel) async { + final multiServer = context.read(); + final serverInfo = liveTvServerInfoForChannel(multiServer, channel); + if (serverInfo == null) { + appLogger.w('No live TV server available for ${channel.displayName}'); + return null; + } + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); + if (client == null) { + appLogger.w('Live TV server ${serverInfo.serverId} is not connected'); + return null; + } + return client.liveTv.startPlayback(channel.key, dvrKey: serverInfo.dvrKey); + } + /// Retry the live stream with degraded direct-stream settings. /// - /// Plex re-tunes the channel for a fresh capture session (the previous one - /// expires while MPV exhausts its reconnect attempts). Jellyfin streams the - /// channel directly with a session-less URL, so retry is just re-opening - /// that URL — degradation knobs apply only to the Plex transcoder branch. + /// The session owns the per-backend recovery: Plex re-tunes the channel + /// for a fresh capture session (the previous one expires while MPV + /// exhausts its reconnect attempts) applying the degradation flags; + /// Jellyfin re-opens its session-less URL. Future _retryLiveStream() async { _liveSeek.cancel(); final currentPlayer = player; if (!mounted || currentPlayer == null) return; - final client = _live.client; + final session = _live.session; + if (session == null) { + appLogger.w('Cannot retry live stream — no session'); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); + unawaited(_handleBackButton()); + return; + } + final ds = _live.fallbackLevel < 1; final dsa = _live.fallbackLevel < 2; + appLogger.i('Retrying live stream: directStream=$ds directStreamAudio=$dsa'); - if (client is PlexClient) { - final channels = widget.live?.channels; - final channelIndex = _live.channelIndex; - final dvrKey = _live.dvrKey; - if (channels == null || channelIndex < 0 || channelIndex >= channels.length || dvrKey == null) { - appLogger.w('Cannot retry live stream — missing session info'); - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); - unawaited(_handleBackButton()); - return; - } - final channel = channels[channelIndex]; - appLogger.i('Retrying live stream (re-tune ${channel.key}): directStream=$ds directStreamAudio=$dsa'); - - // Re-tune to get a fresh capture session — the previous one is dead. - final tuneResult = await client.tuneChannel(dvrKey, channel.key); - if (!mounted || player != currentPlayer) return; - if (tuneResult == null) { - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); - unawaited(_handleBackButton()); - return; - } - - _live.sessionIdentifier = tuneResult.sessionIdentifier; - _live.sessionPath = tuneResult.sessionPath; - _live.transcodeSessionId = generateSessionIdentifier(); - - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _live.transcodeSessionId!, - directStream: ds, - directStreamAudio: dsa, - ); - if (!mounted || player != currentPlayer) return; - if (streamPath == null) { - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); - unawaited(_handleBackButton()); - return; - } - - final streamUrl = client.buildLiveStreamUrl(streamPath); - _live.streamUrl = streamUrl; - _live.markStreamRestartedAtLiveEdge(); - - await _setLiveStreamOptions(currentPlayer); - await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + final recovered = await session.recover(directStream: ds, directStreamAudio: dsa); + if (!mounted || player != currentPlayer) return; + final streamUrl = recovered == null ? null : await recovered.streamUrlAt(); + if (!mounted || player != currentPlayer) return; + if (recovered == null || streamUrl == null) { + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); + unawaited(_handleBackButton()); return; } - final liveStreamUrl = _live.streamUrl; - if (client is JellyfinClient && liveStreamUrl != null) { - appLogger.i('Retrying Jellyfin live stream by re-opening URL'); - _live.markStreamRestartedAtLiveEdge(); - await _setLiveStreamOptions(currentPlayer); - await currentPlayer.open( - Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}), - play: true, - isLive: true, - ); - return; - } + _live.adoptSession(recovered); + _live.markStreamRestartedAtLiveEdge(); - appLogger.w('Cannot retry live stream — no compatible client/URL available'); - showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed)); - unawaited(_handleBackButton()); + await _setLiveStreamOptions(currentPlayer); + await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); } /// Configure MPV options for live streaming. @@ -195,45 +146,25 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { ); } - /// Seek the live TV stream to an absolute epoch second. - /// Creates a new transcode session at the target offset. + /// Seek the live TV stream to an absolute epoch second by rebuilding the + /// stream at the target offset. The session returns null when the backend + /// can't time-shift (Jellyfin), and its capture buffer is null there too, + /// so both guards cover it. Future _seekLivePosition(int targetEpochSeconds) async { final currentPlayer = player; if (currentPlayer == null) return; - if (_live.captureBuffer == null || - _live.sessionPath == null || - _live.sessionIdentifier == null || - _live.transcodeSessionId == null) { - return; - } + final session = _live.session; + final buffer = _live.captureBuffer; + if (session == null || buffer == null) return; - final clamped = targetEpochSeconds.clamp( - _live.captureBuffer!.seekableStartEpoch, - _live.captureBuffer!.seekableEndEpoch, - ); + final clamped = targetEpochSeconds.clamp(buffer.seekableStartEpoch, buffer.seekableEndEpoch); + final offsetSeconds = clamped - buffer.startedAt.round(); - final offsetSeconds = clamped - _live.captureBuffer!.startedAt.round(); + final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds); + if (streamUrl == null || !mounted || player != currentPlayer) return; - // Live seek requires a transcode session — Plex-only by protocol. The - // Plex path populates _live.captureBuffer; the Jellyfin path never does, so - // the early-return above already covers Jellyfin in practice. This - // explicit guard keeps the contract obvious. - final client = _live.client; - if (client is! PlexClient) return; - - final streamPath = await client.buildLiveStreamPath( - sessionPath: _live.sessionPath!, - sessionIdentifier: _live.sessionIdentifier!, - transcodeSessionId: _live.transcodeSessionId!, - offsetSeconds: offsetSeconds, - ); - if (streamPath == null || !mounted || player != currentPlayer) return; - - final streamUrl = client.buildLiveStreamUrl(streamPath); - - _live.streamStartEpoch = _live.captureBuffer!.startedAt + offsetSeconds; - _live.atLiveEdge = - (clamped >= _live.captureBuffer!.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); + _live.streamStartEpoch = buffer.startedAt + offsetSeconds; + _live.atLiveEdge = (clamped >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); _live.playbackStartTime = DateTime.now(); await _setLiveStreamOptions(currentPlayer); @@ -315,83 +246,24 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _setPlayerState(() => _hasFirstFrame.value = false); try { - // Look up the correct client/DVR for this channel's server - final multiServer = context.read(); - final serverInfo = liveTvServerInfoForChannel(multiServer, channel); + // Channel switch IS a fresh start: same resolution path as launch. + final session = await _startLiveSession(channel); + if (session == null || !mounted || player != currentPlayer) return; - if (serverInfo == null) return; - - final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); - if (!mounted || player != currentPlayer) return; - if (resolution != null) { - // Jellyfin: pre-resolved negotiated URL. - await _setLiveStreamOptions(currentPlayer); - await currentPlayer.open( - Media(resolution.url, headers: const {'Accept-Language': 'en'}), - play: true, - isLive: true, - ); - _live.client = genericClient; - _live.dvrKey = serverInfo.dvrKey; - _live.streamUrl = resolution.url; - _live.itemId = channel.key; - _live.sessionIdentifier = resolution.playSessionId; - _live.jellyfin = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId); - _live.captureBuffer = null; - _live.programBeginsAt = null; - _live.programId = null; - _live.durationMs = null; - _live.markStreamRestartedAtLiveEdge(); - if (!mounted) return; - _setPlayerState(() { - _live.channelIndex = newIndex; - _live.channelName = channel.displayName; - }); - _startLiveTimelineUpdates(); - return; - } - - // Plex-only: DVR tune flow (Jellyfin Live TV uses pre-resolved URLs). - final client = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId)); - if (client == null) return; - - final tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key); - if (tuneResult == null || !mounted || player != currentPlayer) return; - - _live.transcodeSessionId = generateSessionIdentifier(); - _live.fallbackLevel = 0; - - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _live.transcodeSessionId!, - ); - if (streamPath == null || !mounted || player != currentPlayer) return; - - final streamUrl = client.buildLiveStreamUrl(streamPath); + final streamUrl = await session.streamUrlAt(); + if (streamUrl == null || !mounted || player != currentPlayer) return; await _setLiveStreamOptions(currentPlayer); await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); - _live.client = client; - _live.dvrKey = serverInfo.dvrKey; - _live.streamUrl = streamUrl; - _live.itemId = channel.key; - _live.programId = tuneResult.metadata.ratingKey; - _live.durationMs = tuneResult.metadata.duration; - - // Reset time-shift state for new channel - _live.captureBuffer = tuneResult.captureBuffer; - _live.programBeginsAt = tuneResult.beginsAt; + _live.adoptSession(session); + _live.fallbackLevel = 0; _live.markStreamRestartedAtLiveEdge(); if (!mounted) return; _setPlayerState(() { _live.channelIndex = newIndex; _live.channelName = channel.displayName; - _live.sessionIdentifier = tuneResult.sessionIdentifier; - _live.sessionPath = tuneResult.sessionPath; }); // Restart timeline heartbeats for the new session diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 4e173044..c3b79b59 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -14,88 +14,54 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { await _setLiveStreamOptions(currentPlayer); if (!attempt.isCurrent) return; - String streamUrl; - if (_live.streamUrl != null) { - streamUrl = _live.streamUrl!; - _live.streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _live.atLiveEdge = true; - } else { - // Tune channel inside the player (shows loading spinner while tuning) - final channels = widget.live?.channels; - final channelIndex = _live.channelIndex; - if (channels == null || channelIndex < 0 || channelIndex >= channels.length) { - throw Exception('No channel to tune'); - } - final channel = channels[channelIndex]; - appLogger.d('Tune: dvrKey=$_live.dvrKey channelKey=${channel.key}'); - final client = _live.client; - if (client is! PlexClient) { - throw StateError( - 'In-player live tuning is Plex-only; got ${client?.runtimeType ?? 'null'}. ' - 'Jellyfin live TV must pass a pre-resolved liveStreamUrl via LiveTvSupport.resolveStreamUrl.', - ); - } - final dvrKey = _live.dvrKey; - if (dvrKey == null) throw Exception('No DVR to tune'); - final tuneResult = await client.tuneChannel(dvrKey, channel.key); - if (tuneResult == null) throw Exception('Failed to tune channel'); + // Start the session inside the player for both backends (loading + // spinner covers Plex's tune / Jellyfin's stream negotiation). + final channel = widget.live!.channel; + final session = await _startLiveSession(channel); + if (session == null) throw Exception('Failed to start live channel'); + if (!mounted || !attempt.isCurrent) return; + _live.adoptSession(session); - _live.sessionIdentifier = tuneResult.sessionIdentifier; - _live.sessionPath = tuneResult.sessionPath; - _live.programId = tuneResult.metadata.ratingKey; - _live.durationMs = tuneResult.metadata.duration; - _live.captureBuffer = tuneResult.captureBuffer; - _live.programBeginsAt = tuneResult.beginsAt; - _live.transcodeSessionId = generateSessionIdentifier(); - - // Show "Watch from Start" dialog when an existing capture session has >60s of history. - // On a fresh tune (no active recording), the buffer is empty so this won't trigger. - int? offsetSeconds; - if (_live.captureBuffer != null && _live.programBeginsAt != null) { - final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final offsetProgramStart = _live.programBeginsAt! - _live.captureBuffer!.startedAt.round(); - // If a session recording started after current program start, offset of program start at will be negative. - // If a session recording started before current program start, offset of program start will be positive. - // If guide data is not available, program start will be equal to current time. - final useProgramStart = offsetProgramStart > 0 && nowEpoch - _live.programBeginsAt! > 60; - final effectiveStart = useProgramStart ? _live.programBeginsAt! : _live.captureBuffer!.seekableStartEpoch; - final elapsed = nowEpoch - effectiveStart; - appLogger.d( - 'Time-shift: buffer=${_live.captureBuffer!.seekableDurationSeconds}s, ' - 'beginsAt=$_live.programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)', - ); - if (elapsed > 60) { - final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch); - if (!mounted) return; - if (watchFromStart == true) { - offsetSeconds = useProgramStart ? offsetProgramStart : _live.captureBuffer!.seekStartSeconds.round(); - } - } - } - - // Build the stream URL (with optional offset for time-shift) - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _live.transcodeSessionId!, - offsetSeconds: offsetSeconds, + // Show "Watch from Start" dialog when an existing capture session has >60s of history. + // On a fresh tune (no active recording), the buffer is empty so this won't trigger. + int? offsetSeconds; + final captureBuffer = session.captureBuffer; + final programBeginsAt = session.program.beginsAt; + if (captureBuffer != null && programBeginsAt != null) { + final nowEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final offsetProgramStart = programBeginsAt - captureBuffer.startedAt.round(); + // If a session recording started after current program start, offset of program start at will be negative. + // If a session recording started before current program start, offset of program start will be positive. + // If guide data is not available, program start will be equal to current time. + final useProgramStart = offsetProgramStart > 0 && nowEpoch - programBeginsAt > 60; + final effectiveStart = useProgramStart ? programBeginsAt : captureBuffer.seekableStartEpoch; + final elapsed = nowEpoch - effectiveStart; + appLogger.d( + 'Time-shift: buffer=${captureBuffer.seekableDurationSeconds}s, ' + 'beginsAt=$programBeginsAt, elapsed=${elapsed}s (need >60 for dialog)', ); - if (streamPath == null || !mounted) throw Exception('Failed to build stream path'); - - streamUrl = client.buildLiveStreamUrl(streamPath); - _live.streamUrl = streamUrl; - - // Track stream start epoch for position calculations - if (offsetSeconds != null) { - _live.streamStartEpoch = _live.captureBuffer!.startedAt + offsetSeconds; - _live.atLiveEdge = false; - } else { - _live.streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _live.atLiveEdge = true; + if (elapsed > 60) { + final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch); + if (!mounted) return; + if (watchFromStart == true) { + offsetSeconds = useProgramStart ? offsetProgramStart : captureBuffer.seekStartSeconds.round(); + } } } - _live.playbackStartTime = DateTime.now(); + // Build the stream URL (with optional offset for time-shift) + final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds); + if (streamUrl == null || !mounted) throw Exception('Failed to build stream path'); + + // Track stream start epoch for position calculations + if (offsetSeconds != null) { + _live.streamStartEpoch = captureBuffer!.startedAt + offsetSeconds; + _live.atLiveEdge = false; + _live.playbackStartTime = DateTime.now(); + } else { + _live.markStreamRestartedAtLiveEdge(); + } + await currentPlayer.setProperty('force-seekable', 'no'); await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); if (!attempt.isCurrent) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index dafa4e52..0cbd016d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -22,9 +22,9 @@ import '../media/media_server_user_profile.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; -import '../services/jellyfin_client.dart'; +import '../media/live_tv_support.dart'; +import '../models/livetv_channel.dart'; import '../services/live_seek_accumulator.dart'; -import '../services/live_session_tracker.dart'; import '../services/plex_client.dart'; import '../utils/session_identifier.dart'; import '../database/app_database.dart'; @@ -332,7 +332,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Live TV session state (tune identity, heartbeats, capture buffer, /// retry ladder) — inert for VOD screens. See [LiveTvSessionState]. - late final LiveTvSessionState _live = LiveTvSessionState(widget.live, itemId: widget.metadata.id); + late final LiveTvSessionState _live = LiveTvSessionState(widget.live); /// Coalesces rapid relative live-TV skips into a single transcode re-open so /// mashing skip-forward can't compound into an overshoot to live (#1253). diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index 2d9fc73a..0c387767 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -3,56 +3,50 @@ import '../media/ids.dart'; import 'package:flutter/material.dart'; -import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; -import '../media/media_server_client.dart'; import '../models/livetv_channel.dart'; import '../providers/multi_server_provider.dart'; import '../screens/video_player/live_tv_session_args.dart'; import '../screens/video_player_screen.dart'; -import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; -/// Navigate to the video player for a live TV channel. -/// -/// Plex flow: pass [liveClient] + [dvrKey] and the player will run the -/// `/livetv/.../tune` POST + transcode decision inside its loading spinner. -/// -/// Jellyfin flow: pass a pre-resolved [liveStreamUrl] (e.g. from -/// [JellyfinClient.buildDirectStreamUrl]) plus [liveClient], and leave -/// [dvrKey] null. -/// The player skips Plex's tune step and points the engine at the URL -/// directly. -/// -/// [backend] is the actual backend serving the channel — the placeholder -/// `MediaItem` carries this through so any in-player `metadata.backend` -/// branch (transcoder hints, watch-state surfaces) sees the right kind. +/// Navigate to the video player for a live TV channel — the single live +/// entry for both backends. The player starts the backend-neutral +/// `LiveTvPlaybackSession` itself (Plex tune / Jellyfin stream negotiation +/// run under its loading spinner), so this only validates that the +/// channel's server is reachable and packages the UX arguments. /// /// [channels] is the full channel list for channel up/down navigation. Future navigateToLiveTv( BuildContext context, { - MediaServerClient? liveClient, - String? dvrKey, - String? liveStreamUrl, - String? liveSessionIdentifier, - required MediaBackend backend, + required MultiServerProvider multiServer, required LiveTvChannel channel, - List? channels, + required List channels, }) async { - assert( - liveStreamUrl != null || (liveClient is PlexClient && dvrKey != null), - 'navigateToLiveTv needs either a pre-resolved stream URL or a Plex client + dvrKey to tune', - ); - final navigator = Navigator.of(context); + final serverInfo = liveTvServerInfoForChannel(multiServer, channel); + if (serverInfo == null) { + showErrorSnackBar(context, 'Live TV server is not available.'); + return; + } + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); + if (client == null) { + showErrorSnackBar(context, 'Live TV server is not connected.'); + return; + } + + final navigator = Navigator.of(context); appLogger.d('Navigating to live channel: ${channel.displayName} (${channel.key})'); + // The placeholder carries the actual backend through so any in-player + // `metadata.backend` branch (transcoder hints, watch-state surfaces) sees + // the right kind. final placeholder = MediaItem( id: channel.key, - backend: backend, + backend: client.backend, kind: MediaKind.clip, title: channel.displayName, serverId: channel.serverId, @@ -65,13 +59,9 @@ Future navigateToLiveTv( pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( metadata: placeholder, live: LiveTvSessionArgs( - channelName: channel.displayName, - streamUrl: liveStreamUrl, + channel: channel, channels: channels, - currentChannelIndex: channels?.indexWhere((ch) => liveTvChannelScopeKey(ch) == liveTvChannelScopeKey(channel)), - dvrKey: dvrKey, - client: liveClient, - sessionIdentifier: liveSessionIdentifier, + currentChannelIndex: channels.indexWhere((ch) => liveTvChannelScopeKey(ch) == liveTvChannelScopeKey(channel)), ), ), transitionDuration: Duration.zero, @@ -81,54 +71,6 @@ Future navigateToLiveTv( unawaited(navigator.push(route)); } -Future tuneAndNavigateToLiveTv( - BuildContext context, { - required MultiServerProvider multiServer, - required LiveTvChannel channel, - required List channels, -}) async { - final serverInfo = liveTvServerInfoForChannel(multiServer, channel); - if (serverInfo == null) { - showErrorSnackBar(context, 'Live TV server is not available.'); - return; - } - - final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (genericClient == null) { - showErrorSnackBar(context, 'Live TV server is not connected.'); - return; - } - final resolution = await genericClient.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); - if (!context.mounted) return; - if (resolution != null) { - await navigateToLiveTv( - context, - liveClient: genericClient, - liveStreamUrl: resolution.url, - liveSessionIdentifier: resolution.playSessionId, - backend: genericClient.backend, - channel: channel, - channels: channels, - ); - return; - } - - final plexClient = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId)); - if (plexClient == null) { - appLogger.w('Failed to resolve live stream URL for ${channel.displayName} on ${genericClient.backend.id}'); - showErrorSnackBar(context, 'Unable to start this live TV channel.'); - return; - } - await navigateToLiveTv( - context, - liveClient: plexClient, - dvrKey: serverInfo.dvrKey, - backend: plexClient.backend, - channel: channel, - channels: channels, - ); -} - LiveTvServerInfo? liveTvServerInfoForChannel(MultiServerProvider multiServer, LiveTvChannel channel) { final serverId = channel.serverId; final dvrKey = channel.liveDvrKey;