fix(automotive): keep music playing while a car is parked, and silence it while driving

Music ran under a foreground service whose lifecycle observer was registered for
App TV, so backgrounding the app on a head unit never paused it and driving never
stopped it. Both halves were wrong for a car: parked audio must survive the app
going to the background, and DD-2 requires it to stop when the vehicle starts
moving.

The vehicle now owns exactly the pause it caused. It is claimed when a restriction
arrives and discharged on the event that proves the resume, so a track the user
paused during a drive stays paused when the car parks. A restriction landing while
the next source is still resolving silences the native player as well as the
session, because the previous track is still coming out of it, and a pause that
throws ends the session rather than leaving audio running in a moving car.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent 7ce5a443fd
commit 3a56218a12
2 changed files with 637 additions and 30 deletions
@@ -14,6 +14,7 @@ import '../../mpv/player/player.dart';
import '../../utils/app_logger.dart';
import '../../utils/notification_permission.dart';
import '../../utils/platform_detector.dart';
import '../car_ux_restrictions_service.dart';
import '../driver_distraction.dart';
import '../media_control_router.dart';
import '../media_controls_manager.dart';
@@ -76,12 +77,19 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
// tvOS has no background-audio session in v1, so it pauses on
// backgrounding. AAOS must stop audio while driving per DD-2. Other
// platforms keep playing under their OS media session.
// backgrounding. A car keeps observing the lifecycle only as the fallback
// authority for vehicles that cannot report UX restrictions; where the
// vehicle does report them, [_onCarRestrictionsChanged] is what starts and
// stops audio, so a parked driver can leave the app and keep listening.
if (PlatformDetector.isAppleTV() || PlatformDetector.isAutomotive()) {
_observesLifecycle = true;
WidgetsBinding.instance.addObserver(this);
}
if (PlatformDetector.isAutomotive()) {
CarUxRestrictionsService.instance.ensureStarted();
CarUxRestrictionsService.instance.listenable.addListener(_onCarRestrictionsChanged);
_observesCarRestrictions = true;
}
}
static const _previousRestartThreshold = Duration(seconds: 3);
@@ -161,6 +169,29 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
bool _resumeAfterInterruption = false;
bool _disposed = false;
bool _observesLifecycle = false;
bool _observesCarRestrictions = false;
/// Set when the vehicle's restrictions stopped playback, so the track can be
/// resumed the moment the car is parked again instead of leaving the driver
/// to hunt for the play button.
bool _pausedByCarRestriction = false;
/// Whether a restriction-owned pause is still in flight, so a lift arriving
/// mid-pause does not read `isPlaying` and conclude nothing needs resuming.
bool _carPauseInFlight = false;
/// Whether a restriction-owned resume is still in flight, so a restriction
/// arriving mid-resume does not read `isPlaying` and conclude nothing is ours.
bool _carResumeInFlight = false;
/// Last value handed to `setBackgroundMode`, so a vehicle answer that changes
/// nothing does not re-enter the native foreground-service policy.
bool? _carBackgroundModeApplied;
/// Whether this vehicle reports its own driver-distraction state. Only then
/// can audio outlive the activity: the restriction signal, not the app being
/// on screen, is what stops playback for driving.
bool get _carBackgroundAudioAvailable => CarUxRestrictionsService.instance.state != CarUxRestrictionState.unknown;
Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt;
@@ -285,15 +316,20 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
if (tracks.isEmpty || _disposed) return;
beginPlayIntent();
_queueSessionRevision++;
// A new queue is a new decision: the vehicle's claim on whatever it stopped
// before must not make parking auto-start this one.
_pausedByCarRestriction = false;
// Android 13+: the background playback notification needs
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
// service run regardless; a denial only hides the notification.
//
// Skipped on a car, where `setBackgroundMode(false)` means the foreground
// service and its notification never start, so there is nothing to
// authorize. The prompt would also take focus, leaving the app briefly not
// resumed, and the automotive gate would then open the track paused and
// silently drop the user's play intent.
// A car asks later, from `_openCurrent`, once the vehicle has answered:
// asking here would decide against the notification before the verdict
// exists. Where the vehicle cannot report restrictions the answer is "never
// ask" anyway — `setBackgroundMode(false)` holds, so the foreground service
// and its notification never start and there is nothing to authorize, while
// the prompt would take focus and the lifecycle fallback would then read the
// app as restricted and drop the user's play intent.
if (!PlatformDetector.isAutomotive()) {
unawaited(NotificationPermission.ensure());
}
@@ -331,13 +367,32 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
await _coordinator.claimMusic();
if (generation != _generation) return;
// Settle the vehicle's answer before the opt-in below reads it: a cold
// start would otherwise configure the session as if the car were mute and
// leave background audio off until the next track.
if (PlatformDetector.isAutomotive()) {
await CarUxRestrictionsService.instance.ensureResolved();
if (generation != _generation) return;
}
final player = _ensurePlayer();
_ensureMediaControls();
// Re-asserted per open (cheap, idempotent): the native side drops the
// background-mode opt-in when the user swipes the task away, so a
// session that survives task removal heals itself here.
// Passing false on AAOS also heals any stale opt-in from an earlier session.
unawaited(_mediaControls?.setBackgroundMode(!PlatformDetector.isAutomotive()));
//
// A car gets the foreground service too, but only once the vehicle can
// report its UX restrictions: that is what stops audio for driving, so
// playback no longer has to be tied to the app being on screen. Without
// that signal the opt-in stays off (and any stale one is healed), because
// the lifecycle fallback would silence a backgrounded track anyway.
final backgroundMode = !PlatformDetector.isAutomotive() || _carBackgroundAudioAvailable;
_carBackgroundModeApplied = backgroundMode;
unawaited(_mediaControls?.setBackgroundMode(backgroundMode));
// The prompt `_startQueue` skipped on a car belongs here, where the verdict
// exists: background audio needs the MediaStyle notification it authorizes.
if (PlatformDetector.isAutomotive() && backgroundMode) {
unawaited(NotificationPermission.ensure());
}
// Clear any native arm left over from the previous item before the open
// replaces it, so a stray transition can't fire mid-switch.
@@ -581,6 +636,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
if (isPlaying && !playbackAllowed) {
unawaited(_player?.pause());
}
// The vehicle's claim is discharged here when a restriction-owned resume only
// reports the transition now: leaving it set would let a later lifted verdict
// restart a track that has since finished and parked at its end.
if (shouldBePlaying && !_carPauseInFlight) _pausedByCarRestriction = false;
if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) {
_setStatus(shouldBePlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
unawaited(_tracker?.sendProgress(shouldBePlaying ? 'playing' : 'paused'));
@@ -942,7 +1001,14 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
@override
Future<void> pause() async {
Future<void> pause() => _pause(byCar: false);
/// [byCar] marks the pause the vehicle's restrictions own, which is the only
/// one resumed when they lift. Any other pause — the user, a media-session
/// command, the sleep timer — takes that ownership away, so parking must not
/// restart a track somebody deliberately stopped while driving.
Future<void> _pause({required bool byCar}) async {
if (!byCar) _pausedByCarRestriction = false;
final player = _player;
if (player == null || _currentTrack == null) return;
final generation = _generation;
@@ -963,28 +1029,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
/// On Apple TV, pause when the app leaves the foreground because tvOS
/// background audio is not attempted in v1. On AAOS, stop audio whenever
/// the app is not resumed to comply with driver-distraction rule DD-2.
/// background audio is not attempted in v1. On a car this is only the
/// fallback authority: [_applyCarPlaybackRestrictions] keeps playing when the
/// vehicle reports no restrictions, so leaving the app while parked keeps the
/// music going.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_disposed) return;
if (PlatformDetector.isAutomotive()) {
if (!automotivePlaybackAllowedNow()) {
_invalidateArmRequests();
_rememberStaleArm();
final player = _player;
if (player != null) {
unawaited(_trySetNext(player, null));
}
if (isPlaying) {
appLogger.d('App restricted on Android Automotive — pausing music playback');
unawaited(pause());
}
return;
}
// Restrictions lifted: re-arm the next track that was cleared on entry.
// Playback itself stays paused until the user asks for it.
if (_currentTrack != null) _requestArmNext();
_applyCarPlaybackRestrictions();
return;
}
if (PlatformDetector.isAppleTV() &&
@@ -995,6 +1048,157 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
}
void _onCarRestrictionsChanged() {
if (_disposed) return;
// A late first answer must reconfigure the session that was opened while the
// vehicle was still silent, otherwise background audio stays off until the
// next track opens.
_reassertCarBackgroundMode();
_applyCarPlaybackRestrictions();
}
/// Applies the foreground-service opt-in (and the notification it needs) for
/// the live session whenever the vehicle's answer changes what we may do.
void _reassertCarBackgroundMode() {
if (!PlatformDetector.isAutomotive() || _mediaControls == null) return;
final enabled = _carBackgroundAudioAvailable;
if (enabled == _carBackgroundModeApplied) return;
_carBackgroundModeApplied = enabled;
unawaited(_mediaControls?.setBackgroundMode(enabled));
if (enabled) unawaited(NotificationPermission.ensure());
}
/// Stop audio while the vehicle requires distraction optimization (`DD-2`),
/// and pick the track back up once it does not.
///
/// Resuming is deliberately limited to vehicles that report their own
/// restrictions, where lifting them means "the car is parked again". Under
/// the lifecycle fallback the same transition only means the app regained
/// focus — it could still be driving, and a dialog dismissal is not a request
/// to play — so those cars keep the previous, conservative behaviour.
void _applyCarPlaybackRestrictions() {
final vehicleReports = _carBackgroundAudioAvailable;
if (!automotivePlaybackAllowedNow()) {
_invalidateArmRequests();
_rememberStaleArm();
final player = _player;
if (player != null) {
unawaited(_trySetNext(player, null));
}
// `isPlaying` reports the session status, which reads `loading` while a
// replacement source resolves — and the previous track is still coming out of
// the native player for the whole of that window, however long the resolver
// takes. Ask the player as well, or driving would not silence it.
final soundingNow = isPlaying || (player?.state.playing ?? false);
// One pause per transition: a car delivers the restriction push and its
// lifecycle states separately, and a second pause launched while the first
// is pending would clear the in-flight flag out from under it.
if (soundingNow && !_carPauseInFlight) {
appLogger.d('Vehicle restricted playback — pausing music');
// The gate owns this pause even when the verdict came from lifecycle: a
// transient car-service restart lands here, and losing ownership would
// leave the track silent for good once the vehicle answers again.
_pausedByCarRestriction = true;
unawaited(_pauseForRestriction());
}
return;
}
// Restrictions lifted: re-arm the next track that was cleared on entry.
if (_currentTrack != null) _requestArmNext();
// Only playback this gate stopped is resumed; a track the user paused
// before driving stays paused, and nothing auto-starts on a fresh session.
if (_pausedByCarRestriction) {
// A restriction-owned pause still in flight keeps the latch: `isPlaying`
// reads stale until that pause lands, so clearing here would skip the
// resume and leave a parked car silent. The re-evaluation does it instead.
if (_carPauseInFlight) return;
// Only a definitive verdict consumes it. While the vehicle cannot answer,
// the app regaining focus is not a reason to forget that this gate stopped
// the track — the answer can still arrive and resume it.
if (!vehicleReports) return;
// A resume already in flight owns the outcome; it re-evaluates when it lands.
if (_carResumeInFlight) return;
if (_currentTrack != null && !isPlaying) {
appLogger.d('Vehicle restrictions lifted — resuming music');
unawaited(_resumeAfterRestriction());
return;
}
// Nothing left to resume, so the gate's claim on this track is discharged.
_pausedByCarRestriction = false;
}
}
/// Resumes what the vehicle stopped, keeping the latch until it actually plays.
///
/// The car can restrict again while this is in flight, and that transition reads
/// the track as already paused, so it neither pauses nor reclaims the latch —
/// [play] then refuses on the closed gate. Discharging the latch up front would
/// strand the track paused on a parked car for good.
Future<void> _resumeAfterRestriction() async {
_carResumeInFlight = true;
var failed = false;
try {
await play();
} catch (e, stackTrace) {
failed = true;
appLogger.w('Failed to resume after vehicle restrictions lifted', error: e, stackTrace: stackTrace);
} finally {
_carResumeInFlight = false;
}
if (_disposed) return;
if (isPlaying) {
_pausedByCarRestriction = false;
return;
}
if (failed) {
// The platform refused outright. Re-evaluating would call straight back into
// here and spin as fast as play() can fail, so drop the claim and leave the
// track for the user; the vehicle is not what is broken here.
_pausedByCarRestriction = false;
return;
}
if (!automotivePlaybackAllowedNow()) {
// The vehicle restricted again mid-resume: keep the claim and let the current
// verdict decide what happens next.
_applyCarPlaybackRestrictions();
return;
}
// The play call landed but the platform has not reported the transition yet — it
// arrives as a state event. Keep the claim, which the next evaluation discharges
// once `isPlaying` is true; re-running now would just issue another play.
}
/// Pauses for the vehicle, then re-reads the verdict.
///
/// The car can release playback while the pause is still in flight — a
/// stop-and-go — and that transition arrives while [isPlaying] is still true,
/// so it cannot resume anything by itself.
Future<void> _pauseForRestriction() async {
_carPauseInFlight = true;
try {
await _pause(byCar: true);
} catch (e, stackTrace) {
appLogger.w('Failed to pause for vehicle restrictions', error: e, stackTrace: stackTrace);
// Fail closed: `DD-2` is not satisfied by having tried. Nothing else is
// coming to stop this — the restriction already fired — so end the session
// rather than leave audio running in a moving car. The native state decides,
// for the same reason the caller checks it: the session reads `loading` while
// a replacement source resolves, with the previous track still audible.
final stillSounding = isPlaying || (_player?.state.playing ?? false);
if (!_disposed && !automotivePlaybackAllowedNow() && stillSounding) {
try {
await stop();
} catch (e, stackTrace) {
appLogger.w('Failed to stop restricted playback', error: e, stackTrace: stackTrace);
}
}
} finally {
_carPauseInFlight = false;
}
if (_disposed || !automotivePlaybackAllowedNow()) return;
_applyCarPlaybackRestrictions();
}
@override
Future<void> next() async {
final nextCursor = _queue.nextIndex(manual: true);
@@ -1250,6 +1454,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
_staleArm = null;
_playContext = null;
_resumeAfterInterruption = false;
// The vehicle's claim dies with the session: whatever plays next is a fresh
// decision, and parking must not resume a queue the user never started.
_pausedByCarRestriction = false;
_setStatus(endStatus, forceNotify: true);
await _teardownPlayerAndControls(awaitStop: true);
@@ -1348,6 +1555,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
WidgetsBinding.instance.removeObserver(this);
_observesLifecycle = false;
}
if (_observesCarRestrictions) {
CarUxRestrictionsService.instance.listenable.removeListener(_onCarRestrictionsChanged);
_observesCarRestrictions = false;
}
_coordinator.unregisterMusicSession(_stopForVideoClaim);
_cancelTimersAndFinalizeTrack();
// Runs to completion synchronously — see the awaitStop: false contract.