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
+8 -2
View File
@@ -374,6 +374,7 @@ Internal architecture, components, and application logic.
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done | | DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done | | DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 3336. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done | | DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 3336. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `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), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `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 jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. 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). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) | | DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done | | DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done | | DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
@@ -404,7 +405,7 @@ Internal architecture, components, and application logic.
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 | | UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 | | UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 | | UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200 | | UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 | | UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 | | UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - | | UR-009 | IR-009, IR-010, IR-011 | - |
@@ -438,7 +439,7 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 | | UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 | | UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 | | UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196 | | UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 | | UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 | | UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 | | UR-043 | IR-027 | DR-055 |
@@ -669,6 +670,11 @@ Internal architecture, components, and application logic.
| UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done | | UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done |
| UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done | | UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done |
| UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done | | UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done |
| UT-194 | Normal audio (no background-audio handoff) keeps queue advance on both skip buttons | DR-201 | Done |
| UT-195 | In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defect | DR-201 | Done |
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
### Integration Tests ### Integration Tests
+4 -3
View File
@@ -264,14 +264,15 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(75); expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32); expect(defined.IR).toBe(32);
// 191 = 187 + four requirements added independently on four branches // 192 = 187 + four requirements added independently on four audit branches,
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
// that landed together: DR-189 (control-bar auto-hide), DR-198 (asset // that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the // scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on // POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
// merge, where it collided). Each branch bumped for its own — merged, // merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side. // they sum. Resolve this by summing, never by taking one side.
expect(defined.DR).toBe(191); expect(defined.DR).toBe(192);
expect(defined.JA).toBe(36); expect(defined.JA).toBe(36);
expect(defined.total).toBe(334); expect(defined.total).toBe(335);
}); });
}); });
@@ -239,6 +239,21 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("previous") 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() { override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
@@ -543,6 +558,14 @@ class JellyTauPlaybackService : MediaSessionService() {
PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS 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 PlaybackStateCompat.ACTION_SEEK_TO
) )
.setState( .setState(
+47 -2
View File
@@ -314,6 +314,10 @@ use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig}; use jellyfin::{HttpClient, HttpConfig};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager; use playback_mode::PlaybackModeManager;
// Only the Android MediaSessionHandler resolves lockscreen skips; on other
// targets this would be an unused import.
#[cfg(target_os = "android")]
use player::seek::{resolve_skip_action, SkipAction};
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter}; use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend is used both for platforms without a native backend AND as a graceful // NullBackend is used both for platforms without a native backend AND as a graceful
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can // fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
@@ -449,14 +453,55 @@ impl MediaSessionHandler {
return; return;
} }
// Skip means different things depending on what is actually playing, so
// the decision belongs here rather than in the Kotlin that drew the
// button: music advances the queue, while a video whose audio is running
// through a background-audio handoff scrubs instead (UR-040). Routed
// through the same spawn-and-seek path as "seek:" above, because
// `seek_absolute` rebuilds the stream during a handoff and must not run
// under the blocking lock (DR-159).
//
// TRACES: UR-040, UR-006 | DR-201
if command == "next" || command == "previous" {
let is_next = command == "next";
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
let action = resolve_skip_action(
is_next,
controller.is_background_audio_active(),
controller.position(),
controller.duration(),
);
let label = if is_next { "next" } else { "previous" };
let result: Result<(), String> = match action {
SkipAction::Advance => if is_next {
controller.next()
} else {
controller.previous()
}
.map_err(|e| e.to_string()),
SkipAction::SeekTo(position) => {
info!(
"[MediaSession] Background audio: '{}' scrubs to {:.1}s",
label, position
);
controller.seek_absolute(position).await
}
};
if let Err(e) = result {
error!("[MediaSession] Skip '{}' failed: {}", label, e);
}
});
return;
}
// Use blocking_lock since this is called from a non-async JNI callback // Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock(); let controller = self.player.blocking_lock();
let result = match command { let result = match command {
"play" => controller.play(), "play" => controller.play(),
"pause" => controller.pause(), "pause" => controller.pause(),
"next" => controller.next(),
"previous" => controller.previous(),
"stop" => controller.stop(), "stop" => controller.stop(),
_ => { _ => {
warn!("[MediaSession] Unknown command: {}", command); warn!("[MediaSession] Unknown command: {}", command);
+139
View File
@@ -59,10 +59,149 @@ pub fn determine_video_seek_strategy(
} }
} }
// The four items below are consumed by the Android MediaSessionHandler; on other
// targets only the tests exercise them, so dead-code analysis would flag them.
/// How far a lockscreen skip-forward jumps while background audio owns playback.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
/// How far a lockscreen skip-back jumps while background audio owns playback.
///
/// Deliberately shorter than the forward jump: the back button is used to replay
/// dialogue just missed, not to travel.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub const SKIP_BACK_SECONDS: f64 = 10.0;
/// What a lockscreen skip button means for the playback that is actually running.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SkipAction {
/// Move to the next/previous queue entry — a track, or an episode.
Advance,
/// Scrub within the current item, to this absolute position in seconds.
SeekTo(f64),
}
/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
///
/// Music gets queue advance, which is what the buttons look like they do. A video
/// whose audio is playing through a background-audio handoff (UR-040) gets a
/// relative scrub instead: there is no meaningful "next track" inside a film, and
/// jumping to the next *episode* because the user wanted to re-hear a line is a
/// much worse outcome than a scrub.
///
/// `is_background_audio` is the whole test, and it is sufficient on its own —
/// the handoff exists only for video, and an episode played through it reports
/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
/// at `PlayerController::auto_advance_to_next_episode`).
///
/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
/// than at a negative offset or past the end, which some backends treat as EOF
/// and would turn a scrub into an unintended advance.
///
/// TRACES: UR-040, UR-006 | DR-201
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn resolve_skip_action(
is_next: bool,
is_background_audio: bool,
position: f64,
duration: Option<f64>,
) -> SkipAction {
if !is_background_audio {
return SkipAction::Advance;
}
let target = if is_next {
position + SKIP_FORWARD_SECONDS
} else {
position - SKIP_BACK_SECONDS
};
let clamped = match duration {
Some(d) if d > 0.0 => target.clamp(0.0, d),
_ => target.max(0.0),
};
SkipAction::SeekTo(clamped)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Music (no background-audio handoff) keeps queue advance on both buttons.
///
/// TRACES: UR-006 | DR-201 | UT-194
#[test]
fn test_skip_advances_queue_for_normal_audio() {
assert_eq!(
resolve_skip_action(true, false, 42.0, Some(300.0)),
SkipAction::Advance
);
assert_eq!(
resolve_skip_action(false, false, 42.0, Some(300.0)),
SkipAction::Advance
);
}
/// The reported bug: in background-audio mode the lockscreen skip buttons
/// advanced to the next/previous episode instead of scrubbing, so trying to
/// re-hear a line jumped out of the film entirely.
///
/// TRACES: UR-040 | DR-201 | UT-195
#[test]
fn test_skip_scrubs_in_background_audio_mode() {
assert_eq!(
resolve_skip_action(true, true, 100.0, Some(3600.0)),
SkipAction::SeekTo(130.0)
);
assert_eq!(
resolve_skip_action(false, true, 100.0, Some(3600.0)),
SkipAction::SeekTo(90.0)
);
}
/// Skipping back near the start clamps to zero rather than going negative,
/// which backends reject (the "Raw(-10)" class of error).
///
/// TRACES: UR-040 | DR-201 | UT-196
#[test]
fn test_skip_back_clamps_at_start() {
assert_eq!(
resolve_skip_action(false, true, 4.0, Some(3600.0)),
SkipAction::SeekTo(0.0)
);
}
/// Skipping forward near the end clamps to the duration instead of running
/// past it, which would read as end-of-stream and advance — the very thing
/// this function exists to prevent.
///
/// TRACES: UR-040 | DR-201 | UT-197
#[test]
fn test_skip_forward_clamps_at_end() {
assert_eq!(
resolve_skip_action(true, true, 3590.0, Some(3600.0)),
SkipAction::SeekTo(3600.0)
);
}
/// An unknown duration still scrubs, and still refuses to go negative.
///
/// TRACES: UR-040 | DR-201 | UT-198
#[test]
fn test_skip_without_duration_still_scrubs() {
assert_eq!(
resolve_skip_action(true, true, 10.0, None),
SkipAction::SeekTo(40.0)
);
assert_eq!(
resolve_skip_action(false, true, 3.0, None),
SkipAction::SeekTo(0.0)
);
}
/// Test video seek strategy for local files /// Test video seek strategy for local files
#[test] #[test]
fn test_seek_strategy_local_file() { fn test_seek_strategy_local_file() {