Files
plezy/lib/screens/video_player/parts/live_tv.dart
T
2026-06-06 11:45:34 +02:00

404 lines
16 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// Start periodic timeline heartbeats for live TV transcode session.
void _startLiveTimelineUpdates() {
final generation = ++_liveTimelineGeneration;
_liveTimelineTimer?.cancel();
_liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) {
if (generation != _liveTimelineGeneration) return;
final state = player?.state.playing == true ? 'playing' : 'paused';
_sendLiveTimeline(state);
});
// Delay initial heartbeat to let the transcode session stabilize.
// Sending time=0 immediately after player.open() causes the server
// to spawn a duplicate transcode job with offset=-1 that 404s.
Future.delayed(const Duration(seconds: 3), () {
if (_liveTimelineTimer != null && generation == _liveTimelineGeneration) {
final state = player?.state.playing == true ? 'playing' : 'paused';
_sendLiveTimeline(state);
}
});
}
void _stopLiveTimelineUpdates() {
_liveTimelineGeneration++;
_liveTimelineTimer?.cancel();
_liveTimelineTimer = null;
}
Future<void> _sendLiveTimeline(String state) async {
final client = _liveClient;
final playbackTime = _livePlaybackStartTime != null
? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds
: 0;
if (client is PlexClient) {
final sessionId = _liveSessionIdentifier;
final sessionPath = _liveSessionPath;
if (sessionId == null || sessionPath == null) return;
try {
// Use the program ratingKey from tune metadata, not the channel key
final ratingKey = _liveProgramId ?? _liveItemId ?? widget.metadata.id;
// 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(_liveDurationMs ?? 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(() {
_captureBuffer = updatedBuffer;
_isAtLiveEdge =
(_currentPositionEpoch >=
updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
});
}
} catch (e) {
appLogger.d('Plex live timeline update failed', error: e);
}
return;
}
if (client is JellyfinClient) {
await _jellyfinLiveSession.report(
client: client,
itemId: _liveItemId ?? widget.metadata.id,
state: state,
position: Duration(milliseconds: playbackTime),
duration: Duration(milliseconds: _liveDurationMs ?? 0),
);
return;
}
}
/// 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.
Future<void> _retryLiveStream() async {
_liveSeek.cancel();
final client = _liveClient;
final ds = _liveStreamFallbackLevel < 1;
final dsa = _liveStreamFallbackLevel < 2;
if (client is PlexClient) {
final channels = widget.liveChannels;
final channelIndex = _liveChannelIndex;
final dvrKey = _liveDvrKey;
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 (tuneResult == null || !mounted) {
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
unawaited(_handleBackButton());
return;
}
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
_transcodeSessionId = generateSessionIdentifier();
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
directStream: ds,
directStreamAudio: dsa,
);
if (streamPath == null || !mounted) {
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
unawaited(_handleBackButton());
return;
}
final streamUrl = client.buildLiveStreamUrl(streamPath);
_liveStreamUrl = streamUrl;
_livePlaybackStartTime = DateTime.now();
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
return;
}
final liveStreamUrl = _liveStreamUrl;
if (client is JellyfinClient && liveStreamUrl != null) {
appLogger.i('Retrying Jellyfin live stream by re-opening URL');
_livePlaybackStartTime = DateTime.now();
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
await _setLiveStreamOptions();
await player!.open(Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
return;
}
appLogger.w('Cannot retry live stream — no compatible client/URL available');
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? t.liveTv.liveStreamFailed));
unawaited(_handleBackButton());
}
/// Configure MPV options for live streaming.
/// The official Plex Media Player does not set client-side reconnect options —
/// reconnection is handled by the server's transcoder on the input side.
Future<void> _setLiveStreamOptions() async {
await player!.setProperty('force-seekable', 'no');
}
/// The raw live playback position as an absolute epoch second
/// (`_streamStartEpoch + player position`).
int get _rawPositionEpoch => (_streamStartEpoch + (player?.state.position.inSeconds ?? 0)).round();
/// The current playback position as an absolute epoch second (for live TV time-shift).
///
/// While a relative skip is pending/settling, this returns the accumulator's
/// target rather than the raw sum. During a live re-open `_streamStartEpoch`
/// is advanced to the target before the new stream's position resets to ~0,
/// so the raw sum transiently overshoots; pinning to the pending target keeps
/// seek accumulation and the live-edge heartbeat ([_sendLiveTimeline]) correct
/// (close #1253).
int get _currentPositionEpoch => _liveSeek.pendingEpoch ?? _rawPositionEpoch;
/// Show "Watch from Start" / "Watch Live" dialog.
/// Returns true if user chose "Watch from start", false for "Watch Live", null if dismissed.
Future<bool?> _showWatchFromStartDialog(int effectiveStartEpoch, int nowEpoch) {
final minutesAgo = ((nowEpoch - effectiveStartEpoch) / 60).round();
return showOptionPickerDialog<bool>(
context,
title: t.liveTv.joinSession,
options: [
(icon: Symbols.replay_rounded, label: t.liveTv.watchFromStart(minutes: minutesAgo), value: true),
(icon: Symbols.live_tv_rounded, label: t.liveTv.watchLive, value: false),
],
);
}
/// Seek the live TV stream to an absolute epoch second.
/// Creates a new transcode session at the target offset.
Future<void> _seekLivePosition(int targetEpochSeconds) async {
if (_captureBuffer == null ||
_liveSessionPath == null ||
_liveSessionIdentifier == null ||
_transcodeSessionId == null) {
return;
}
final clamped = targetEpochSeconds.clamp(_captureBuffer!.seekableStartEpoch, _captureBuffer!.seekableEndEpoch);
final offsetSeconds = clamped - _captureBuffer!.startedAt.round();
// Live seek requires a transcode session — Plex-only by protocol. The
// Plex path populates _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 = _liveClient;
if (client is! PlexClient) return;
final streamPath = await client.buildLiveStreamPath(
sessionPath: _liveSessionPath!,
sessionIdentifier: _liveSessionIdentifier!,
transcodeSessionId: _transcodeSessionId!,
offsetSeconds: offsetSeconds,
);
if (streamPath == null || !mounted) return;
final streamUrl = client.buildLiveStreamUrl(streamPath);
_streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds;
_isAtLiveEdge = (clamped >= _captureBuffer!.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
_livePlaybackStartTime = DateTime.now();
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (mounted) _setPlayerState(() {});
}
/// Current seekable epoch window for [_liveSeek], or null when there is no
/// live capture buffer.
LiveSeekBounds? _liveSeekBounds() {
final buffer = _captureBuffer;
if (buffer == null) return null;
return (start: buffer.seekableStartEpoch, end: buffer.seekableEndEpoch);
}
/// Rebuild and refresh live-edge state when [_liveSeek]'s pending target
/// changes (a skip was accumulated, or the post-seek pin was released).
void _onLiveSeekTargetChanged() {
if (!mounted) return;
final pending = _liveSeek.pendingEpoch;
final buffer = _captureBuffer;
_setPlayerState(() {
if (pending != null && buffer != null) {
_isAtLiveEdge = pending >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds;
}
});
}
/// Re-open the live stream at [targetEpochSeconds], logging (rather than
/// throwing) on failure. A throw is rethrown so [_liveSeek] releases its
/// pending pin; direct callers catch it.
Future<void> _runLiveSeek(int targetEpochSeconds) async {
try {
await _seekLivePosition(targetEpochSeconds);
} catch (e, st) {
appLogger.w('Live time-shift seek failed', error: e, stackTrace: st);
rethrow;
}
}
/// Seek the live stream to an absolute epoch (scrubber / jump-to-live). Drops
/// any pending relative-skip burst first so a queued seek can't override it.
Future<void> _seekLiveToEpoch(int targetEpochSeconds) async {
_liveSeek.cancel();
try {
await _runLiveSeek(targetEpochSeconds);
} catch (_) {
// Already logged; an absolute live seek is best-effort.
}
}
/// Jump to the live edge of the capture buffer.
Future<void> _jumpToLiveEdge() async {
if (_captureBuffer == null) return;
await _seekLiveToEpoch(_captureBuffer!.seekableEndEpoch);
}
Future<void> _switchLiveChannel(int delta) async {
final channels = widget.liveChannels;
if (channels == null || channels.isEmpty) return;
if (_isSwitchingChannel) return; // debounce concurrent switches
final newIndex = _liveChannelIndex + delta;
if (newIndex < 0 || newIndex >= channels.length) return;
_isSwitchingChannel = true;
_liveSeek.cancel();
// Stop old session heartbeats and notify server
_stopLiveTimelineUpdates();
await _sendLiveTimeline('stopped');
final channel = channels[newIndex];
appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})');
if (!mounted) return;
_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);
if (serverInfo == null) return;
final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId));
final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey);
if (resolution != null) {
// Jellyfin: pre-resolved negotiated URL.
await _setLiveStreamOptions();
await player!.open(Media(resolution.url, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
_liveClient = genericClient;
_liveDvrKey = serverInfo.dvrKey;
_liveStreamUrl = resolution.url;
_liveItemId = channel.key;
_liveSessionIdentifier = resolution.playSessionId;
_jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId);
_livePlaybackStartTime = DateTime.now();
_captureBuffer = null;
_programBeginsAt = null;
_liveProgramId = null;
_liveDurationMs = null;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
if (!mounted) return;
_setPlayerState(() {
_liveChannelIndex = newIndex;
_liveChannelName = 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) return;
_transcodeSessionId = generateSessionIdentifier();
_liveStreamFallbackLevel = 0;
final streamPath = await client.buildLiveStreamPath(
sessionPath: tuneResult.sessionPath,
sessionIdentifier: tuneResult.sessionIdentifier,
transcodeSessionId: _transcodeSessionId!,
);
if (streamPath == null || !mounted) return;
final streamUrl = client.buildLiveStreamUrl(streamPath);
await _setLiveStreamOptions();
await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
_liveClient = client;
_liveDvrKey = serverInfo.dvrKey;
_liveStreamUrl = streamUrl;
_liveItemId = channel.key;
_livePlaybackStartTime = DateTime.now();
_liveProgramId = tuneResult.metadata.ratingKey;
_liveDurationMs = tuneResult.metadata.duration;
// Reset time-shift state for new channel
_captureBuffer = tuneResult.captureBuffer;
_programBeginsAt = tuneResult.beginsAt;
_streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0;
_isAtLiveEdge = true;
if (!mounted) return;
_setPlayerState(() {
_liveChannelIndex = newIndex;
_liveChannelName = channel.displayName;
_liveSessionIdentifier = tuneResult.sessionIdentifier;
_liveSessionPath = tuneResult.sessionPath;
});
// Restart timeline heartbeats for the new session
_startLiveTimelineUpdates();
} catch (e) {
appLogger.e('Failed to switch channel', error: e);
if (mounted) showErrorSnackBar(context, e.toString());
} finally {
_isSwitchingChannel = false;
}
}
bool get _hasNextChannel =>
widget.isLive &&
widget.liveChannels != null &&
_liveChannelIndex >= 0 &&
_liveChannelIndex < (widget.liveChannels!.length - 1);
bool get _hasPreviousChannel => widget.isLive && widget.liveChannels != null && _liveChannelIndex > 0;
}