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:
co-authored by
Claude Fable 5
parent
73be8ab1c8
commit
28bc4a5df8
@@ -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 &&
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user