refactor(player): live playback drives LiveTvPlaybackSession

This commit is contained in:
edde746
2026-06-12 16:15:20 +02:00
parent 23a423fe85
commit dcfe0f8308
10 changed files with 204 additions and 445 deletions
+8
View File
@@ -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<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
}
@@ -34,13 +34,12 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
///
/// 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<void> tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
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
+1 -1
View File
@@ -555,7 +555,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
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) {
@@ -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<LiveTvChannel>? 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});
}
@@ -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() {
@@ -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();
}
}
+81 -209
View File
@@ -28,138 +28,89 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
}
Future<void> _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<LiveTvPlaybackSession?> _startLiveSession(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
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<void> _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<void> _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<MultiServerProvider>();
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
@@ -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;
+3 -3
View File
@@ -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<VideoPlayerScreen> 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).
+25 -83
View File
@@ -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<void> navigateToLiveTv(
BuildContext context, {
MediaServerClient? liveClient,
String? dvrKey,
String? liveStreamUrl,
String? liveSessionIdentifier,
required MediaBackend backend,
required MultiServerProvider multiServer,
required LiveTvChannel channel,
List<LiveTvChannel>? channels,
required List<LiveTvChannel> 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<void> 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<void> navigateToLiveTv(
unawaited(navigator.push<bool>(route));
}
Future<void> tuneAndNavigateToLiveTv(
BuildContext context, {
required MultiServerProvider multiServer,
required LiveTvChannel channel,
required List<LiveTvChannel> 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;