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.
@@ -1,11 +1,16 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/car_ux_restrictions_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_source_resolver.dart';
import 'package:plezy/services/music/music_playback_service_impl.dart';
import 'package:plezy/utils/notification_permission.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -31,6 +36,18 @@ class _RecordingMediaControlsManager extends music_fakes.FakeMediaControlsManage
}
}
/// Holds a source resolve open, which is the window where the session reads
/// `loading` while the previous track is still coming out of the native player.
class _GatedResolver extends music_fakes.FakeMusicSourceResolver {
Completer<void>? gate;
@override
Future<MusicSource> resolve(MediaItem track) async {
await gate?.future;
return super.resolve(track);
}
}
class _Harness {
_Harness._(this.service, this.controls, this.players, this.serverManager);
@@ -41,13 +58,13 @@ class _Harness {
music_fakes.FakePlayer get player => players.single;
factory _Harness.create() {
factory _Harness.create({_GatedResolver? resolver}) {
final controls = _RecordingMediaControlsManager();
final players = <music_fakes.FakePlayer>[];
final serverManager = MultiServerManager();
final service = MusicPlaybackServiceImpl(
serverManager: serverManager,
resolver: music_fakes.FakeMusicSourceResolver(),
resolver: resolver ?? music_fakes.FakeMusicSourceResolver(),
audioPlayerFactory: () {
final player = music_fakes.FakePlayer();
players.add(player);
@@ -87,6 +104,356 @@ void main() {
tearDown(() {
TvDetectionService.debugReset();
CarUxRestrictionsService.debugSetOverride(null);
CarUxRestrictionsService.instance.debugReset();
binding.defaultBinaryMessenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, null);
});
/// Answers `getState` only once [gate] completes, so a test can open a track
/// while the vehicle is still silent — the cold-start ordering that
/// pre-seeded state cannot reproduce.
void answerVehicleWhen(Completer<void> gate, {required bool restricted}) {
CarUxRestrictionsService.instance.debugReset();
binding.defaultBinaryMessenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, (call) async {
if (call.method != 'getState') return null;
await gate.future;
return <String, Object?>{'supported': true, 'requiresDistractionOptimization': restricted};
});
}
test('an open that beats the vehicle answer still ends up with background audio on', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
final gate = Completer<void>();
answerVehicleWhen(gate, restricted: false);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
final started = harness.start([_track('one')]);
await pumpEventQueue();
expect(harness.controls.backgroundModeCalls, isEmpty, reason: 'the open waits for the vehicle');
gate.complete();
await started;
await pumpEventQueue();
expect(harness.controls.backgroundModeCalls.last, isTrue);
expect(harness.player.state.playing, isTrue);
});
test('a vehicle answer arriving after the open reconfigures the live session', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
// Never answers: the open times out into the lifecycle fallback, exactly as
// a car whose service is not up yet behaves.
answerVehicleWhen(Completer<void>(), restricted: false);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]).timeout(const Duration(seconds: 10));
expect(harness.controls.backgroundModeCalls.last, isFalse, reason: 'no signal yet: stay conservative');
// The platform pushes its first verdict once the car service connects.
await binding.defaultBinaryMessenger.handlePlatformMessage(
CarUxRestrictionsService.channel.name,
CarUxRestrictionsService.channel.codec.encodeMethodCall(
const MethodCall('onChanged', <String, Object?>{'supported': true, 'requiresDistractionOptimization': false}),
),
(_) {},
);
await pumpEventQueue();
expect(harness.controls.backgroundModeCalls.last, isTrue, reason: 'the live session must be reconfigured');
});
test('a session opened while the car service is still connecting still gets background audio', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.instance.debugReset();
// Production shape: `getState` answers immediately with "no verdict yet, one is coming", and the
// real answer arrives later as a push. A cold start must not conclude the car is mute.
binding.defaultBinaryMessenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, (call) async {
if (call.method != 'getState') return null;
return <String, Object?>{'supported': false, 'pending': true, 'requiresDistractionOptimization': true};
});
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
final started = harness.start([_track('one')]);
await pumpEventQueue();
expect(harness.controls.backgroundModeCalls, isEmpty, reason: 'the open waits for the promised verdict');
await binding.defaultBinaryMessenger.handlePlatformMessage(
CarUxRestrictionsService.channel.name,
CarUxRestrictionsService.channel.codec.encodeMethodCall(
const MethodCall('onChanged', <String, Object?>{'supported': true, 'requiresDistractionOptimization': false}),
),
(_) {},
);
await started;
await pumpEventQueue();
expect(harness.controls.backgroundModeCalls.last, isTrue);
expect(harness.player.state.playing, isTrue);
});
test('a parked vehicle keeps music playing while the app is backgrounded', () async {
// The review complaint: switching to navigation while parked killed audio,
// because the old gate could not tell "parked" from "not in front".
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
await harness.start([_track('one')]);
expect(harness.player.state.playing, isTrue);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isTrue);
expect(harness.service.status, MusicPlaybackStatus.playing);
expect(harness.controls.backgroundModeCalls, contains(true));
});
test('a driving vehicle stops music and parking again resumes it', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
expect(harness.player.state.playing, isTrue);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse, reason: 'DD-2: driving must stop audio');
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
expect(harness.player.state.playing, isTrue, reason: 'parking again resumes what driving stopped');
});
test('a pause taken during the drive is not undone by parking', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse);
// A steering-wheel pause, or an expiring sleep timer, while the vehicle is
// already holding audio: the car no longer owns this pause.
await harness.service.pause();
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse, reason: 'parking must not restart what someone deliberately stopped');
});
test('a car service restart does not silence a background track for good', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isTrue, reason: 'parked background audio');
// The car service dies: no verdict, so the lifecycle fallback stops the
// backgrounded track.
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unknown);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse);
// It comes back and reports a parked vehicle. The gate stopped this track, so
// the gate resumes it — the user never touched anything.
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isTrue, reason: 'a transient service restart must not be permanent');
});
test('foregrounding during a car service outage does not forget who paused', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
// The service dies, so the lifecycle fallback stops the backgrounded track.
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unknown);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse);
// The user opens Plezy again while the vehicle still cannot answer. Focus is
// not a verdict, so nothing auto-starts here...
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse, reason: 'regaining focus is not a request to play');
// ...but the gate still owns the pause, so the verdict resumes it.
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
expect(harness.player.state.playing, isTrue);
});
test('a restriction landing mid-resume does not strand the track paused', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse);
// Parked again, but the resume is slow — and the car starts moving before it
// lands, so `play()` refuses on the closed gate.
final playGate = Completer<void>();
harness.player.playGate = playGate;
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
harness.player.playGate = null;
playGate.complete();
await pumpEventQueue();
expect(harness.player.state.playing, isFalse, reason: 'driving again: it must not be playing');
// Parking once more must still resume it: the gate never stopped owning this pause.
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
expect(harness.player.state.playing, isTrue);
});
test('parking does not start a queue the user never played', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse, reason: 'driving stopped the first track');
// The user stops that session while still driving, then queues something else
// without asking for playback: on an empty queue this parks on the first track.
await harness.service.stop();
harness.service.addToEnd([_track('two')]);
await pumpEventQueue();
// A new session builds its own player, so read the latest one.
expect(harness.players.last.state.playing, isFalse);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
expect(harness.players.last.state.playing, isFalse, reason: 'the gate never stopped this queue');
expect(harness.service.status, isNot(MusicPlaybackStatus.playing));
});
test('one driving transition pauses once, however many times it is reported', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
final pausesBefore = harness.player.pauseCalls;
// A car reports the restriction push and its lifecycle states separately, and
// the pause is slow: a second one launched meanwhile would clear the in-flight
// flag out from under the first.
final pauseGate = Completer<void>();
harness.player.pauseGate = pauseGate;
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
await pumpEventQueue();
expect(harness.player.pauseCalls - pausesBefore, 1, reason: 'the in-flight pause is the one that lands');
harness.player.pauseGate = null;
pauseGate.complete();
await pumpEventQueue();
expect(harness.player.state.playing, isFalse);
});
test('a lift arriving while the restriction pause is in flight still resumes', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
expect(harness.player.state.playing, isTrue);
// Hold the pause the way a slow platform call would.
final pauseGate = Completer<void>();
harness.player.pauseGate = pauseGate;
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
// Stop-and-go: parked again before the pause has even landed, so the lift
// still reads the track as playing.
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpEventQueue();
harness.player.pauseGate = null;
pauseGate.complete();
await pumpEventQueue();
expect(
harness.player.state.playing,
isTrue,
reason: 'a pause landing after the lift must not leave a parked car silent',
);
});
test('a vehicle that cannot report restrictions keeps the conservative opt-out', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unknown);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final harness = _Harness.create();
addTearDown(harness.dispose);
await harness.start([_track('one')]);
expect(harness.controls.backgroundModeCalls, isNot(contains(true)));
});
test('play is refused while automotive lifecycle is not resumed', () async {
@@ -234,6 +601,35 @@ void main() {
expect(harness.player.state.playing, isFalse);
});
test('driving silences audio that is still playing while the next source resolves', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
final resolver = _GatedResolver();
final harness = _Harness.create(resolver: resolver);
addTearDown(harness.dispose);
final second = _track('two');
await harness.start([_track('one'), second]);
expect(harness.player.state.playing, isTrue);
// Skipping holds the session in `loading` until the source resolves, and the
// previous track keeps sounding on the native player throughout.
resolver.gate = Completer<void>();
unawaited(harness.service.next());
await pumpEventQueue();
expect(harness.service.status, MusicPlaybackStatus.loading);
expect(harness.player.state.playing, isTrue, reason: 'the old track is still audible');
CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await pumpEventQueue();
expect(harness.player.state.playing, isFalse, reason: 'DD-2: driving must stop audio, resolver or not');
resolver.gate!.complete();
await pumpEventQueue();
});
test('media-session pause still stops audio while automotive playback is restricted', () async {
TvDetectionService.debugSetAutomotiveOverride(true);
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);