feat(player): handle stop/skip/speed media-session commands

The platforms advertise stop, skip forward/backward, and playback-rate
commands by default, but both the music and video handlers silently
dropped them (Android Auto/Bluetooth stop and FF/rewind did nothing;
iOS/macOS showed a dead rate control). setControlsEnabled now manages
those controls: music handles Stop and in-track skips and stops
advertising a speed control; video handles Stop (exit, matching the
companion remote), skips via a shared relative-seek helper, and rate
changes through player.setRate. Skip commands stay off on iOS/macOS
where they would displace the next/previous lock-screen buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
edde746
2026-07-06 15:28:24 +02:00
co-authored by Claude Fable 5
parent 73be8ab1c8
commit 28bc4a5df8
7 changed files with 167 additions and 25 deletions
@@ -451,8 +451,19 @@ class FakeMediaControlsManager extends MediaControlsManager {
bool force = false,
}) async {}
final List<({bool canGoNext, bool canStop, bool canSkip, bool canSetSpeed})> controlSyncs = [];
@override
Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {}
Future<void> setControlsEnabled({
bool canGoNext = false,
bool canGoPrevious = false,
bool canSeek = false,
bool canStop = false,
bool canSkip = false,
bool canSetSpeed = false,
}) async {
controlSyncs.add((canGoNext: canGoNext, canStop: canStop, canSkip: canSkip, canSetSpeed: canSetSpeed));
}
@override
Future<void> clear() async {
@@ -739,6 +750,46 @@ void main() {
expect(h.player.playCalls, 1);
});
test('OS stop command stops the session', () async {
await h.playTracks([t1, t2]);
final player = h.player;
h.controls.eventsCtrl.add(const StopEvent());
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
expect(player.disposed, isTrue);
});
test('OS skip commands seek within the track, clamped to its bounds', () async {
await h.playTracks([t1, t2]);
h.player.setPosition(const Duration(seconds: 30));
h.controls.eventsCtrl.add(const SkipForwardEvent(Duration(seconds: 15)));
await pumpEventQueue();
expect(h.player.seeks, [const Duration(seconds: 45)]);
h.controls.eventsCtrl.add(const SkipBackwardEvent(null)); // default interval
await pumpEventQueue();
expect(h.player.seeks.last, const Duration(seconds: 30));
h.player.setPosition(const Duration(seconds: 5));
h.controls.eventsCtrl.add(const SkipBackwardEvent(Duration(seconds: 15)));
await pumpEventQueue();
expect(h.player.seeks.last, Duration.zero);
});
test('music advertises stop and skip but never a speed control', () async {
await h.playTracks([t1, t2]);
expect(h.controls.controlSyncs, isNotEmpty);
final last = h.controls.controlSyncs.last;
expect(last.canStop, isTrue);
expect(last.canSkip, isTrue);
expect(last.canSetSpeed, isFalse);
});
test('interruption without shouldResume stays paused', () async {
await h.playTracks([t1]);