Plezy declares appCategory="video", so on Android Automotive OS it is a parked app bound by car app quality DD-2/DD-3: audio must stop when the vehicle starts driving and must not be resumable while driving. Two paths kept audio alive. Music playback ran under a mediaPlayback foreground service whose lifecycle observer was registered for Apple TV only, so it never paused when Android backgrounded the app. Video pausing hung off AppLifecycleState.hidden, which Flutter only synthesizes once Android delivers onStop; a car without the Automotive compatibility mode delivers onPause alone, which maps to AppLifecycleState.inactive and the player ignored. Gate every path that can start audio on a new lifecycle predicate, automotivePlaybackAllowed, which permits playback on a car only while the app is resumed and fails closed on an unknown lifecycle state. That covers explicit play, gapless arming and track transitions, live retry and channel switch, frame-rate-match resume, VOD/live startup, and the queue navigation commands of the OS media session, plus a last-resort pause for when the platform player resumes itself on native audio-focus regain. Playback authority on the media-session router is deliberately left alone: the router consumes a denied event, so gating it would swallow PauseEvent and leave the OS unable to stop audio. Reacting to lifecycle callbacks is the mechanism the platform documents as sufficient, so no android.car dependency is added. The music queue no longer requests POST_NOTIFICATIONS on a car, where the foreground service and its notification never start: there is nothing to authorize, and the prompt would take focus and make the gate discard the first play intent. Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV verdict, so a rotary-only head unit no longer inherits the leanback experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE, which cars lack, so the app's UI cannot stay on screen while driving, and nothing forces a preferred orientation on a fixed-orientation display.
374 lines
15 KiB
Dart
374 lines
15 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: automotivePlaybackAllowedNow(),
|
|
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: automotivePlaybackAllowedNow(),
|
|
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 previousHasFirstFrame = _hasFirstFrame.value;
|
|
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
|
|
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;
|
|
_hasRenderedFirstFrame = false;
|
|
});
|
|
replacementOpenStarted = true;
|
|
await currentPlayer.open(
|
|
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
|
play: automotivePlaybackAllowedNow(),
|
|
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 = previousHasFirstFrame;
|
|
_hasRenderedFirstFrame = previousHasRenderedFirstFrame;
|
|
});
|
|
}
|
|
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;
|
|
}
|