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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user