fix(player): lockscreen skip scrubs instead of advancing in background audio

onSkipToNext/onSkipToPrevious forwarded a bare next/previous to Rust, which
always advanced the queue. Correct for music, wrong for a video whose audio is
running through a background-audio handoff (UR-040): pressing skip to re-hear a
line jumped to the next episode instead of scrubbing.

resolve_skip_action in player/seek.rs maps the command to Advance or SeekTo, and
is_background_audio_active() is the whole test — the handoff exists only for
video, and an episode played through it reports MediaType::Audio, so media type
cannot distinguish the case. Forward 30s, back 10s, both clamped to [0, duration]
so a skip near either end cannot seek negative or read as EOF and advance.

Routed through the same spawn-then-seek_absolute path as the scrubber, because a
handoff seek re-opens the stream and must not run under the blocking lock
(DR-159). Kotlin keeps sending the opaque command; it only gains FAST_FORWARD/
REWIND in the PlaybackStateCompat so the system stops drawing skip arrows for a
control that scrubs. The remote-volume action block is deliberately untouched:
the handoff never applies to cast sessions, where skip really does mean advance.

Tests written first and watched fail (left: Advance, right: SeekTo). 706 Rust
tests pass, clippy 0, coverage 90%.
This commit is contained in:
2026-08-16 23:54:43 +02:00
parent 42e7d86ec4
commit 6dfc6b259a
5 changed files with 221 additions and 7 deletions
@@ -239,6 +239,21 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("previous")
}
// Fast-forward/rewind map onto the same two commands on purpose.
// Rust decides whether a skip advances the queue or scrubs
// +30s/-10s, based on whether a background-audio handoff owns
// playback (DR-201); routing these separately would put that
// decision in two places and let them disagree.
override fun onFastForward() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Fast-forward pressed")
nativeOnMediaCommand("next")
}
override fun onRewind() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Rewind pressed")
nativeOnMediaCommand("previous")
}
override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
nativeOnMediaCommand("stop")
@@ -543,6 +558,14 @@ class JellyTauPlaybackService : MediaSessionService() {
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
// Advertised so the system draws seek affordances alongside the
// skip arrows: during a background-audio handoff the backend
// resolves skip to a +30s/-10s scrub rather than a queue advance
// (DR-201), and a control that scrubs should not look like one
// that changes track. Rust owns which of the two a press means;
// these only describe what the session can do.
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
PlaybackStateCompat.ACTION_SEEK_TO
)
.setState(