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
@@ -13,26 +13,12 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (mounted) unawaited(_restartOrPlayPrevious());
};
receiver.onSeekForward = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
if (widget.isLive && _live.captureBuffer != null) {
_liveSeek.seekBy(seekSeconds);
return;
}
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: seekSeconds));
await _seekPlayback(target);
await _seekRelative(Duration(seconds: settings.read(SettingsService.seekTimeSmall)));
};
receiver.onSeekBackward = () async {
if (player == null) return;
final settings = await SettingsService.getInstance();
final seekSeconds = settings.read(SettingsService.seekTimeSmall);
if (widget.isLive && _live.captureBuffer != null) {
_liveSeek.seekBy(-seekSeconds);
return;
}
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: seekSeconds));
await _seekPlayback(target);
await _seekRelative(Duration(seconds: -settings.read(SettingsService.seekTimeSmall)));
};
receiver.onVolumeUp = () async {
if (player == null) return;
@@ -87,6 +87,11 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState {
canGoNext: canNavigateEpisodes,
canGoPrevious: canNavigateEpisodes,
canSeek: canSeek,
canStop: true,
// In-track skips work on live TV too through the capture buffer.
canSkip: true,
// Rate changes don't apply to a live stream.
canSetSpeed: !widget.isLive,
);
}
@@ -1,5 +1,9 @@
part of '../../video_player_screen.dart';
/// Fallback for OS skip commands that arrive without an interval (the
/// platforms normally send one — Android hardcodes 15s).
const _defaultMediaControlSkip = Duration(seconds: 15);
extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
void _queueScrubPreviewLoad({
required MediaItem metadata,
@@ -331,6 +335,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
} else if (event is PreviousTrackEvent) {
appLogger.d('Media control: Previous track event received');
unawaited(_restartOrPlayPrevious());
} else if (event is StopEvent) {
// Same semantics as the companion remote's stop: exit the player.
appLogger.d('Media control: Stop event received');
unawaited(_handleBackButton());
} else if (event is SkipForwardEvent) {
appLogger.d('Media control: Skip forward event received (${event.interval})');
unawaited(_seekRelative(event.interval ?? _defaultMediaControlSkip));
} else if (event is SkipBackwardEvent) {
appLogger.d('Media control: Skip backward event received (${event.interval})');
unawaited(_seekRelative(-(event.interval ?? _defaultMediaControlSkip)));
} else if (event is SetSpeedEvent) {
// UI, Discord, and the media-session state all follow reactively
// via streams.rate — same unguarded path as keyboard shortcuts.
appLogger.d('Media control: Set speed event received (${event.speed}x)');
unawaited(activePlayer!.setRate(event.speed));
}
});
@@ -14,6 +14,18 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState {
await _restartPlexTranscodeAt(target);
}
/// Relative seek shared by the companion remote and the OS media-control
/// skip commands, including the live-TV capture-buffer branch.
Future<void> _seekRelative(Duration delta) async {
final currentPlayer = player;
if (currentPlayer == null) return;
if (widget.isLive && _live.captureBuffer != null) {
_liveSeek.seekBy(delta.inSeconds);
return;
}
await _seekPlayback(currentPlayer.state.position + delta);
}
bool get _usesPlexVodTranscodeSeekPolicy {
return _isTranscoding &&
!widget.isLive &&
+48 -8
View File
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:os_media_controls/os_media_controls.dart';
import 'package:rate_limiter/rate_limiter.dart';
@@ -25,6 +27,9 @@ class MediaControlsManager {
bool? _lastCanGoNext;
bool? _lastCanGoPrevious;
bool? _lastCanSeek;
bool? _lastCanStop;
bool? _lastCanSkip;
bool? _lastCanSetSpeed;
bool _updatesSuspended = false;
MediaControlsManager() {
@@ -110,16 +115,31 @@ class MediaControlsManager {
}
}
/// Enable or disable next/previous track controls
/// Enable or disable transport controls in the OS media session.
///
/// This should be called based on content type and playback mode.
/// For example:
/// - Episodes: Enable both if there are adjacent episodes
/// - Playlist items: Enable based on playlist position
/// - Movies: Usually disabled
Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {
/// next/previous/seek reflect the content (adjacent episodes, playlist
/// position, seekability). stop/skip/speed reflect what the active surface
/// actually handles — the platform sides enable several commands by
/// default, so anything the caller leaves disabled here is explicitly
/// un-advertised rather than shown as a dead button.
///
/// [canSkip] is never honored on iOS/macOS: enabling the
/// MPRemoteCommandCenter skip commands displaces the next/previous track
/// buttons on the lock screen / Control Center, and next/previous are the
/// primary transport there. Android's fast-forward/rewind actions are
/// independent of next/previous, so skip is safe to advertise.
Future<void> setControlsEnabled({
bool canGoNext = false,
bool canGoPrevious = false,
bool canSeek = false,
bool canStop = false,
bool canSkip = false,
bool canSetSpeed = false,
}) async {
if (_updatesSuspended) return;
final effectiveCanSkip = canSkip && !Platform.isIOS && !Platform.isMacOS;
try {
final controlsToEnable = <MediaControl>[];
final controlsToDisable = <MediaControl>[];
@@ -133,6 +153,17 @@ class MediaControlsManager {
if (canSeek != _lastCanSeek) {
(canSeek ? controlsToEnable : controlsToDisable).add(MediaControl.seek);
}
if (canStop != _lastCanStop) {
(canStop ? controlsToEnable : controlsToDisable).add(MediaControl.stop);
}
if (effectiveCanSkip != _lastCanSkip) {
(effectiveCanSkip ? controlsToEnable : controlsToDisable)
..add(MediaControl.skipForward)
..add(MediaControl.skipBackward);
}
if (canSetSpeed != _lastCanSetSpeed) {
(canSetSpeed ? controlsToEnable : controlsToDisable).add(MediaControl.changeSpeed);
}
if (controlsToEnable.isEmpty && controlsToDisable.isEmpty) return;
@@ -146,7 +177,13 @@ class MediaControlsManager {
_lastCanGoNext = canGoNext;
_lastCanGoPrevious = canGoPrevious;
_lastCanSeek = canSeek;
appLogger.d('Media controls updated - Previous: $canGoPrevious, Next: $canGoNext, Seek: $canSeek');
_lastCanStop = canStop;
_lastCanSkip = effectiveCanSkip;
_lastCanSetSpeed = canSetSpeed;
appLogger.d(
'Media controls updated - Previous: $canGoPrevious, Next: $canGoNext, Seek: $canSeek, '
'Stop: $canStop, Skip: $effectiveCanSkip, Speed: $canSetSpeed',
);
} catch (e) {
appLogger.w('Failed to set media controls enabled state', error: e);
}
@@ -174,6 +211,9 @@ class MediaControlsManager {
_lastCanGoNext = null;
_lastCanGoPrevious = null;
_lastCanSeek = null;
_lastCanStop = null;
_lastCanSkip = null;
_lastCanSetSpeed = null;
appLogger.d('Media controls cleared');
} catch (e) {
appLogger.w('Failed to clear media controls', error: e);
@@ -639,6 +639,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
// Previous always restarts the track even at queue head.
canGoPrevious: true,
canSeek: true,
canStop: true,
// In-track skips: Bluetooth/steering-wheel fast-forward and rewind
// buttons map here on Android. (Never surfaced on iOS/macOS — see
// MediaControlsManager.setControlsEnabled.)
canSkip: true,
// Music always plays at 1.0 — never advertise a speed control.
),
);
}
@@ -657,6 +663,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
unawaited(previous());
} else if (event is SeekEvent) {
unawaited(seek(event.position));
} else if (event is StopEvent) {
unawaited(stop());
} else if (event is SkipForwardEvent) {
unawaited(_seekRelative(event.interval ?? _defaultSkipInterval));
} else if (event is SkipBackwardEvent) {
unawaited(_seekRelative(-(event.interval ?? _defaultSkipInterval)));
} else if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) {
// Remember whether we were playing so interruption-end/route-return
// can resume. Unlike video, music resumes even while backgrounded —
@@ -676,6 +688,23 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
unawaited(play());
}
}
// SetSpeedEvent is deliberately unhandled: music always plays at 1.0 and
// the control is not advertised — but Linux MPRIS exposes an always-
// writable Rate property, so the event can still arrive. The periodic
// playback-state update reasserts speed 1.0.
}
static const _defaultSkipInterval = Duration(seconds: 15);
/// In-track relative seek for OS skip commands, clamped to the track.
Future<void> _seekRelative(Duration delta) async {
final player = _player;
if (player == null) return;
var target = player.currentPosition + delta;
if (target < Duration.zero) target = Duration.zero;
final max = duration;
if (max != null && target > max) target = max;
await player.seek(target);
}
// ---------------------------------------------------------------------
@@ -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]);