diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index e1f92c1f..3a51b394 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -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 pause() async { + Future 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 _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 _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 _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 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. diff --git a/test/services/music/music_playback_automotive_test.dart b/test/services/music/music_playback_automotive_test.dart index 9f3c8f0b..c88946d7 100644 --- a/test/services/music/music_playback_automotive_test.dart +++ b/test/services/music/music_playback_automotive_test.dart @@ -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? gate; + + @override + Future 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 = []; 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 gate, {required bool restricted}) { + CarUxRestrictionsService.instance.debugReset(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, (call) async { + if (call.method != 'getState') return null; + await gate.future; + return {'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(); + 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(), 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', {'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 {'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', {'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(); + 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(); + 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(); + 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(); + 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);