diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index 5651d9da..fabb6473 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -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; diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index f54dbeab..96e73a97 100644 --- a/lib/screens/video_player/parts/media_controls.dart +++ b/lib/screens/video_player/parts/media_controls.dart @@ -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, ); } diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index c85cead2..c9eec394 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -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)); } }); diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index ef8aa6df..873504dc 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -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 _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 && diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 434ef7f0..f38a7264 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -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 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 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 = []; final controlsToDisable = []; @@ -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); diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index e1be0e42..7d8dacc2 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -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 _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); } // --------------------------------------------------------------------- diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 96bea525..7b3250f7 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -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 setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {} + Future 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 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]);