Files
plezy/lib/screens/video_player/parts/playback_start.dart
T
edde746 41ffaa7f2b fix(automotive): stop playback while a vehicle restricts the app
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.
2026-07-28 23:28:25 +02:00

414 lines
18 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
Future<void> _startPlayback() async {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final attempt = _beginPlaybackAttempt(currentPlayer);
_hasRenderedFirstFrame = false;
_hasFatalPlaybackError = false;
// Live TV mode: bypass standard playback initialization
if (widget.isLive) {
try {
_hasFirstFrame.value = false;
await currentPlayer.requestAudioFocus();
await _setLiveStreamOptions(currentPlayer);
if (!attempt.isCurrent) return;
// 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) {
_abandonLiveSession(session);
return;
}
_live.adoptSession(session);
// 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 (elapsed > 60) {
final watchFromStart = await _showWatchFromStartDialog(effectiveStart, nowEpoch);
if (!mounted) return;
if (watchFromStart == true) {
offsetSeconds = useProgramStart ? offsetProgramStart : captureBuffer.seekStartSeconds.round();
}
}
}
// 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: !PlatformDetector.isAutomotive(),
isLive: true,
);
if (!attempt.isCurrent) return;
_trackManager?.cacheExternalSubtitles(const []);
await _initVideoFilterAndPip();
if (!mounted || player != currentPlayer) return;
if (mounted) {
// Live TV never commits a PlaybackSession, so the session-derived
// versions/mediaInfo getters already read empty here.
_setPlayerState(() {
_isPlayerInitialized = true;
});
_trackManager?.mediaInfo = null;
}
if (PlatformDetector.isAutomotive()) {
await _playWithPlaybackIntent(currentPlayer);
}
} catch (e, st) {
appLogger.e('Failed to start live TV playback', error: e, stackTrace: st);
unawaited(_sendLiveTimeline('stopped'));
if (mounted) {
showErrorSnackBar(context, e.toString());
unawaited(_handleBackButton());
}
}
return;
}
// Capture providers before async gaps
final offlineWatchService = context.read<OfflineWatchSyncService>();
var primaryMediaOpened = false;
try {
PlaybackContext playbackContext;
if (_offlineLibraryMode) {
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
playbackContext = await playbackResolver.resolve(
PlaybackInitializationOptions(
metadata: _currentMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _requestedMediaSourceId,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
),
offlineLibraryMode: true,
);
if (playbackContext.result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
} else {
// Online path: `_playbackDataFuture` was kicked off in `_initializePlayer`
// in parallel with MPV setup. Quality preset + server capabilities +
// headers were resolved there too. Just await the result.
final playbackDataFuture = _playbackDataFuture;
if (playbackDataFuture == null) {
throw StateError('Playback data was not prepared before playback start');
}
playbackContext = await playbackDataFuture;
if (!mounted || player != currentPlayer) return;
if (playbackContext.result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
}
}
final result = playbackContext.result;
final streamHeaders = playbackContext.streamHeaders;
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: _currentMetadata,
result: result,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
);
if (!attempt.isCurrent) return;
// Initial start has no previous session to protect, so commit as soon
// as the resolve lands (reload-style flows commit at the open
// boundary instead).
var session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
subtitleSelection: subtitleSelection,
);
_commitPlaybackSession(session);
// Primary refresh-rate path: when metadata provides FPS, Android players
// can switch before creating decoders. MPV still needs a startup refresh
// when MediaCodec has already produced its first paused frame.
final settingsService = await SettingsService.getInstance();
if (!attempt.isCurrent) return;
final displayCriteria = result.mediaInfo?.displayCriteria;
var audioFocusReady = false;
Future<void> ensureAudioFocus() async {
if (audioFocusReady) return;
final focusFuture = _audioFocusFuture;
if (focusFuture != null) {
await focusFuture;
_audioFocusFuture = null;
} else {
await currentPlayer.requestAudioFocus();
}
audioFocusReady = true;
}
final frameRatePlan = await _prepareFrameRateForOpen(
currentPlayer: currentPlayer,
settingsService: settingsService,
preKnownFps: displayCriteria?.fps,
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: result.videoUrl != null,
ensureAudioFocus: ensureAudioFocus,
);
if (frameRatePlan == null) return;
final shouldHoldPlaybackStart = frameRatePlan.holdPlaybackStart;
// When a Watch Together session is active the sync layer owns the
// start: open paused everywhere and let the host coordinate one
// simultaneous group start.
final wtOwnsStart = _watchTogetherOwnsPlaybackStart();
Completer<void>? wtStartupHold;
late _ExternalSubtitleOpenPlan externalSubtitlePlan;
// Open video through Player
if (result.videoUrl != null) {
// Reset first frame flag and frame rate retry counter for new video
_hasFirstFrame.value = false;
_frameRate.resetForNewItem();
if (frameRatePlan.countsAsApplied) {
_frameRate.applied = true;
}
// Request audio focus before starting playback (Android)
// This causes other media apps (Spotify, podcasts, etc.) to pause.
// Fired in parallel with MPV setup in `_initializePlayer`; we await
// the in-flight future here (usually already resolved).
await ensureAudioFocus();
if (!attempt.isCurrent) return;
final resumePosition = await _resolveOpenResumePosition(
metadata: _currentMetadata,
isOffline: _isOfflinePlayback,
offlineWatchService: offlineWatchService,
);
if (!mounted || player != currentPlayer) return;
await _primeDisplayCriteria(
player: currentPlayer,
settingsService: settingsService,
displayCriteria: displayCriteria,
isTranscoding: result.isTranscoding,
);
frameRatePlan.armStartupRefreshGate(currentPlayer);
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
);
final shouldAutoPlay =
!shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup;
// Backends that support at-open sidecars receive them with open()
// so tracks are discovered in a single prepare/loadfile cycle. Any
// backend that cannot do that still uses the post-open sub-add path.
final openTiming = _playbackOpenTiming(
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
isTranscoding: result.isTranscoding,
isLocalMedia: _isOfflinePlayback,
selectedVersion: result.selectedVersion,
timing: openTiming,
headers: streamHeaders,
play: shouldAutoPlay && !PlatformDetector.isAutomotive(),
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => attempt.isCurrent,
onMediaAvailabilityChanged: (available) => primaryMediaOpened = available,
);
if (!openResult.didOpen || !attempt.isCurrent) return;
if (openResult.sidecarFallbackUsed) {
session = _commitSidecarFallbackSession(session);
subtitleSelection = session.subtitleSelection;
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []);
}
// Attach player to Watch Together session for sync (if in session).
// With a frame-rate startup gate pending, sync readiness waits for
// its release so the group start can't fire mid display switch.
if (mounted && !_isOfflinePlayback) {
if (wtOwnsStart && shouldHoldPlaybackStart) {
wtStartupHold = Completer<void>();
}
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
_notifyWatchTogetherMediaChange();
}
if (shouldAutoPlay && PlatformDetector.isAutomotive()) {
await _playWithPlaybackIntent(currentPlayer);
if (!attempt.isCurrent) return;
}
} else {
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
waitForFileLoaded: false,
);
}
// Versions/mediaInfo come from the committed session; rebuild so the
// controls pick them up.
if (mounted) {
final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId));
_resetScrubPreviewForNewItem(metadata: _currentMetadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient);
await _initVideoFilterAndPip();
if (!attempt.isCurrent) return;
if (player == currentPlayer) {
// Auto-PiP: set up callback for API 26-30 path and initial state
if (_autoPipEnabled) {
void autoPipEnteringCallback() {
if (!mounted || player != currentPlayer) return;
_setAndroidAutoPipTransitionInFlight(true, reason: 'native_auto_pip_entering');
_preparePipFiltersForEntry();
}
_autoPipEnteringCallback = autoPipEnteringCallback;
PipService.onAutoPipEntering = autoPipEnteringCallback;
if (currentPlayer.state.playing) {
unawaited(_updateAutoPipState(isPlaying: true));
}
}
// Shader Service (MPV only)
_shaderService = ShaderService(currentPlayer);
if (_shaderService!.isSupported) {
// Ambient Lighting Service
_ambientLightingService = AmbientLightingService(currentPlayer);
_shaderService!.ambientLightingService = _ambientLightingService;
_videoFilterManager?.ambientLightingService = _ambientLightingService;
await _applySavedShaderPreset();
await _restoreAmbientLighting();
}
}
if (!attempt.isCurrent) return;
// Track manager: owns track selection, external subtitle loading, and Plex
// immediate stream writes. Jellyfin persists selected stream indexes through
// playback progress reports instead.
_trackManager = _buildTrackManager(
forPlayer: currentPlayer,
metadata: _currentMetadata,
plexClient: mediaClient is PlexClient ? mediaClient : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: subtitleSelection.primaryTrack,
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
);
// Store only the active sidecars for re-use after backend fallback.
_trackManager!.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart;
await _applyTracksAfterOpen(
trackManager: _trackManager!,
externalSubtitlePlan: externalSubtitlePlan,
// When a startup gate below owns the resume, skip this one to
// avoid a double-play. Post-open external-subtitle paths are the
// exception: after they attach we must resume once so mpv can
// produce the startup frame that the decoder-refresh gate is waiting
// for.
// Watch Together stays paused for the group start, so selection is
// armed through the resume-skipped branch.
shouldResumeAfterSubtitleLoad: () =>
(!shouldHoldPlaybackStart || resumeForStartupFrame) && !wtOwnsStart && mounted && player == currentPlayer,
applySelectionWhenResumeSkipped: wtOwnsStart && !shouldHoldPlaybackStart,
);
await _releaseFrameRateStartupGate(
currentPlayer: currentPlayer,
settingsService: settingsService,
plan: frameRatePlan,
resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate(
currentPlayer: currentPlayer,
externalSubtitlePlan: externalSubtitlePlan,
reason: reason,
shouldResume: !wtOwnsStart,
watchTogetherOwnsStart: wtOwnsStart,
wtStartupHold: wtStartupHold,
),
playbackResumedForStartupFrame: resumeForStartupFrame,
);
// Backstop: if the gate never ran its resume path (unmounted race),
// don't leave Watch Together readiness held forever.
if (wtStartupHold != null && !wtStartupHold.isCompleted) {
wtStartupHold.complete();
}
}
} on PlaybackException catch (e, st) {
appLogger.w('Playback initialization failed', error: e, stackTrace: st);
if (attempt.isCurrent && mounted) {
if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
showErrorSnackBar(context, e.message);
}
} catch (e, st) {
appLogger.e('Failed to start playback', error: e, stackTrace: st);
if (attempt.isCurrent && mounted) {
if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
}