355 lines
14 KiB
Dart
355 lines
14 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 = ++_live.timelineGeneration;
|
|
_live.timelineTimer?.cancel();
|
|
_live.timelineTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
|
if (generation != _live.timelineGeneration) 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 (_live.timelineTimer != null && generation == _live.timelineGeneration) {
|
|
final state = player?.state.playing == true ? 'playing' : 'paused';
|
|
_sendLiveTimeline(state);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopLiveTimelineUpdates() {
|
|
_live.timelineGeneration++;
|
|
_live.timelineTimer?.cancel();
|
|
_live.timelineTimer = null;
|
|
}
|
|
|
|
Future<void> _sendLiveTimeline(String state) async {
|
|
final requestSession = _live.session;
|
|
if (requestSession == null) return;
|
|
final requestGeneration = _live.timelineGeneration;
|
|
// 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;
|
|
|
|
try {
|
|
await runLiveTimelineReport(
|
|
requestSession: requestSession,
|
|
requestGeneration: requestGeneration,
|
|
state: state,
|
|
positionMs: playbackTime,
|
|
currentSession: () => _live.session,
|
|
currentGeneration: () => _live.timelineGeneration,
|
|
isMounted: () => mounted,
|
|
commit: (updatedBuffer) {
|
|
_setPlayerState(() {
|
|
_live.captureBuffer = updatedBuffer;
|
|
_live.atLiveEdge =
|
|
(_currentPositionEpoch >=
|
|
updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
|
|
});
|
|
},
|
|
);
|
|
} catch (e) {
|
|
appLogger.d('Live timeline update failed', error: e);
|
|
}
|
|
}
|
|
|
|
/// Fire-and-forget a stopped heartbeat for a session that started but was
|
|
/// never adopted (unmount or superseded mid-start) so the backend tears
|
|
/// down its tuner/transcode resources instead of waiting for a timeout.
|
|
void _abandonLiveSession(LiveTvPlaybackSession session) {
|
|
unawaited(() async {
|
|
try {
|
|
await session.reportTimeline(state: 'stopped', positionMs: 0, durationMs: session.program.durationMs ?? 0);
|
|
} catch (e) {
|
|
appLogger.d('Failed to stop abandoned live session', 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.
|
|
///
|
|
/// 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 generation = _playbackGeneration;
|
|
bool isCurrent() => _isCurrentPlaybackGeneration(generation, currentPlayer);
|
|
final session = _live.session;
|
|
if (session == null) {
|
|
_live.retrying = false;
|
|
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');
|
|
|
|
final result = await runLiveStreamRetry<LiveTvPlaybackSession>(
|
|
recover: () => session.recover(directStream: ds, directStreamAudio: dsa),
|
|
lookupStreamUrl: (recovered) => recovered.streamUrlAt(),
|
|
applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer),
|
|
open: (streamUrl) =>
|
|
currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true),
|
|
isCurrent: isCurrent,
|
|
adoptSession: (recovered) {
|
|
_live.adoptSession(recovered);
|
|
_live.markStreamRestartedAtLiveEdge();
|
|
},
|
|
discardSession: _abandonLiveSession,
|
|
reportFailure: (error, stackTrace) {
|
|
appLogger.e('Failed to recover live stream', error: error, stackTrace: stackTrace);
|
|
_live.retryFailed = true;
|
|
showGlobalErrorSnackBar(t.messages.liveStreamInterrupted);
|
|
},
|
|
onFinished: () {
|
|
if (isCurrent()) _live.retrying = false;
|
|
},
|
|
);
|
|
if (result == LiveStreamRetryResult.succeeded && isCurrent()) {
|
|
_live.retryFailed = false;
|
|
}
|
|
}
|
|
|
|
/// 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(Player player) => player.setProperty('force-seekable', 'no');
|
|
|
|
/// The raw live playback position as an absolute epoch second
|
|
/// (`_live.streamStartEpoch + player position`).
|
|
int get _rawPositionEpoch => (_live.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 `_live.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 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;
|
|
final session = _live.session;
|
|
final buffer = _live.captureBuffer;
|
|
if (session == null || buffer == null) return;
|
|
|
|
final clamped = targetEpochSeconds.clamp(buffer.seekableStartEpoch, buffer.seekableEndEpoch);
|
|
final offsetSeconds = clamped - buffer.startedAt.round();
|
|
|
|
final streamUrl = await session.streamUrlAt(offsetSeconds: offsetSeconds);
|
|
if (streamUrl == null || !mounted || player != currentPlayer) return;
|
|
|
|
_live.streamStartEpoch = buffer.startedAt + offsetSeconds;
|
|
_live.atLiveEdge = (clamped >= buffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds);
|
|
_live.playbackStartTime = DateTime.now();
|
|
|
|
await _setLiveStreamOptions(currentPlayer);
|
|
await currentPlayer.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 = _live.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 = _live.captureBuffer;
|
|
_setPlayerState(() {
|
|
if (pending != null && buffer != null) {
|
|
_live.atLiveEdge = 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 (_live.captureBuffer == null) return;
|
|
await _seekLiveToEpoch(_live.captureBuffer!.seekableEndEpoch);
|
|
}
|
|
|
|
Future<void> _switchLiveChannel(int delta) async {
|
|
final channels = widget.live?.channels;
|
|
if (channels == null || channels.isEmpty) return;
|
|
final newIndex = _live.channelIndex + delta;
|
|
if (newIndex < 0 || newIndex >= channels.length) return;
|
|
final currentPlayer = player;
|
|
if (currentPlayer == null) return;
|
|
|
|
final transitionLease = _tryAcquirePlaybackTransition(_PlaybackTransition.switchingChannel);
|
|
if (transitionLease == null) return; // debounce concurrent switches
|
|
bool isCurrentChannelSwitch() =>
|
|
mounted &&
|
|
player == currentPlayer &&
|
|
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingChannel);
|
|
_liveSeek.cancel();
|
|
|
|
final previousSession = _live.session;
|
|
final channel = channels[newIndex];
|
|
appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})');
|
|
|
|
LiveTvPlaybackSession? session;
|
|
var replacementOpenStarted = false;
|
|
try {
|
|
// Channel switch IS a fresh start: same resolution path as launch. Keep
|
|
// the old session alive until the replacement stream is actually open so
|
|
// a failed zap does not tell the server to reclaim the still-playing
|
|
// tuner/transcode session.
|
|
session = await _startLiveSession(channel);
|
|
if (session == null) return;
|
|
if (!isCurrentChannelSwitch()) {
|
|
_abandonLiveSession(session);
|
|
return;
|
|
}
|
|
|
|
final streamUrl = await session.streamUrlAt();
|
|
if (streamUrl == null || !isCurrentChannelSwitch()) {
|
|
_abandonLiveSession(session);
|
|
return;
|
|
}
|
|
|
|
await _setLiveStreamOptions(currentPlayer);
|
|
if (!isCurrentChannelSwitch()) {
|
|
_abandonLiveSession(session);
|
|
return;
|
|
}
|
|
|
|
_setPlayerState(() => _hasFirstFrame.value = false);
|
|
replacementOpenStarted = true;
|
|
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
|
if (!isCurrentChannelSwitch()) {
|
|
_abandonLiveSession(session);
|
|
return;
|
|
}
|
|
|
|
// The new stream is now the active local playback. Stop the old heartbeat
|
|
// and send its terminal timeline before adopting the replacement session.
|
|
_stopLiveTimelineUpdates();
|
|
if (previousSession != null) {
|
|
await _sendLiveTimeline('stopped');
|
|
}
|
|
if (!isCurrentChannelSwitch()) {
|
|
_abandonLiveSession(session);
|
|
return;
|
|
}
|
|
|
|
_live.adoptSession(session);
|
|
_live.fallbackLevel = 0;
|
|
_live.markStreamRestartedAtLiveEdge();
|
|
|
|
if (!mounted) return;
|
|
_setPlayerState(() {
|
|
_live.channelIndex = newIndex;
|
|
_live.channelName = channel.displayName;
|
|
});
|
|
|
|
// Restart timeline heartbeats for the new session
|
|
_startLiveTimelineUpdates();
|
|
} catch (e) {
|
|
// A session that tuned but was never adopted (streamUrlAt/open threw)
|
|
// would otherwise hold its server-side tuner until the backend times out.
|
|
final orphan = session;
|
|
if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan);
|
|
if (!isCurrentChannelSwitch()) return;
|
|
if (replacementOpenStarted && mounted && _live.session == previousSession) {
|
|
_setPlayerState(() => _hasFirstFrame.value = true);
|
|
}
|
|
appLogger.e('Failed to switch channel', error: e);
|
|
if (mounted) showErrorSnackBar(context, e.toString());
|
|
} finally {
|
|
_releasePlaybackTransition(transitionLease);
|
|
}
|
|
}
|
|
|
|
bool get _hasNextChannel {
|
|
final channels = widget.live?.channels;
|
|
return channels != null && _live.channelIndex >= 0 && _live.channelIndex < channels.length - 1;
|
|
}
|
|
|
|
bool get _hasPreviousChannel => widget.live?.channels != null && _live.channelIndex > 0;
|
|
}
|