refactor(player): live playback drives LiveTvPlaybackSession
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user