diff --git a/docs/requirements.md b/docs/requirements.md index 6ea01432..32059091 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -253,6 +253,7 @@ Internal architecture, components, and application logic. | DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done | | DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done | | DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done | +| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to ``, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done | | DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done | --- @@ -420,6 +421,8 @@ Internal architecture, components, and application logic. | UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done | | UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done | | UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done | +| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done | +| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done | ### Integration Tests diff --git a/docs/traceability.md b/docs/traceability.md index e8879b48..b78d16ba 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -1,15 +1,15 @@ # Code Traceability Matrix -**Generated:** 8/2/2026, 6:10:09 PM +**Generated:** 8/3/2026, 6:07:57 PM ## Summary -- **Total Files Scanned:** 305 -- **Total TRACES Found:** 338 +- **Total Files Scanned:** 307 +- **Total TRACES Found:** 340 - **Requirements Covered:** - User Requirements (UR): 58 - Integration Requirements (IR): 15 - - Development Requirements (DR): 90 + - Development Requirements (DR): 91 - Jellyfin API Requirements (JA): 24 ## Requirements by Type @@ -26,7 +26,7 @@ IR-003, IR-004, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-020, ### Development Requirements (DR) ``` -DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087, DR-088, DR-089, DR-090, DR-091, DR-092, DR-093, DR-095, DR-096, DR-097, DR-098, DR-099 +DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087, DR-088, DR-089, DR-090, DR-091, DR-092, DR-093, DR-095, DR-096, DR-097, DR-098, DR-099, DR-100 ``` ### Jellyfin API Requirements (JA) @@ -2004,6 +2004,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 21 - **Context:** `Unknown` +### DR-100 + +**Locations:** 2 file(s) + +- **File:** [`src/lib/components/player/playerSurface.test.ts`](src/lib/components/player/playerSurface.test.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/playerSurface.ts`](src/lib/components/player/playerSurface.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` + ### JA-001 **Locations:** 1 file(s) @@ -2468,7 +2479,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UR-005 -**Locations:** 66 file(s) +**Locations:** 68 file(s) - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2258) - **Line:** 2258 @@ -2503,6 +2514,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/components/player/VideoPlayer.touchScrub.test.ts`](src/lib/components/player/VideoPlayer.touchScrub.test.ts#L21) - **Line:** 21 - **Context:** `Unknown` +- **File:** [`src/lib/components/player/playerSurface.test.ts`](src/lib/components/player/playerSurface.test.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/playerSurface.ts`](src/lib/components/player/playerSurface.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` - **File:** [`src/lib/components/BottomUi.svelte`](src/lib/components/BottomUi.svelte#L16) - **Line:** 16 - **Context:** `Unknown` @@ -2807,14 +2824,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 441 - **Context:** `Unknown` -### UR-014 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/playlist.rs`](src-tauri/src/commands/playlist.rs#L4) - - **Line:** 4 - - **Context:** `Unknown` - ### UR-017 **Locations:** 9 file(s) @@ -2965,6 +2974,59 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` +### UR-025 + +**Locations:** 16 file(s) + +- **File:** [`src/lib/utils/validation.test.ts`](src/lib/utils/validation.test.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.test.ts`](src/lib/services/playbackReporting.test.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L10) + - **Line:** 10 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L21) + - **Line:** 21 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L89) + - **Line:** 89 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L122) + - **Line:** 122 + - **Context:** `Unknown` +- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L7) + - **Line:** 7 + - **Context:** `Unknown` +- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L64) + - **Line:** 64 + - **Context:** `Unknown` +- **File:** [`src/lib/services/skipReporting.ts`](src/lib/services/skipReporting.ts#L14) + - **Line:** 14 + - **Context:** `Unknown` +- **File:** [`src/lib/services/skipReporting.test.ts`](src/lib/services/skipReporting.test.ts#L9) + - **Line:** 9 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L156) + - **Line:** 156 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/mod.rs`](src-tauri/src/commands/mod.rs#L2) + - **Line:** 2 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/playback_reporting.rs`](src-tauri/src/commands/playback_reporting.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/sync.rs`](src-tauri/src/commands/sync.rs#L5) + - **Line:** 5 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` + ### UR-026 **Locations:** 19 file(s) @@ -3617,6 +3679,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 37 - **Context:** `Unknown` +### IT-003 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) + - **Line:** 8 + - **Context:** `Unknown` + ### IT-004 **Locations:** 1 file(s) @@ -3650,15 +3720,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 2396 - **Context:** `Unknown` -### IT-017 +### UT-003 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L441) - - **Line:** 441 - - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) + - **Line:** 535 + - **Context:** `pub enum AddPosition {` -### UT-003 +### UT-004 **Locations:** 1 file(s) @@ -3682,16 +3752,13 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 2 - **Context:** `Unknown` -### UT-014 +### UT-012 -**Locations:** 2 file(s) +**Locations:** 1 file(s) -- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - - **Line:** 311 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` +- **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) + - **Line:** 81 + - **Context:** `pub fn cancel(&mut self) {` ### UT-015 @@ -3719,17 +3786,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - - **Line:** 311 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` - -### UT-018 - -**Locations:** 2 file(s) - - **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - **Line:** 311 - **Context:** `Unknown` @@ -3763,17 +3819,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - - **Line:** 311 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` - -### UT-022 - -**Locations:** 2 file(s) - - **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - **Line:** 311 - **Context:** `Unknown` @@ -3830,7 +3875,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 245 - **Context:** `Unknown` -### UT-028 +### UT-029 **Locations:** 1 file(s) @@ -3838,7 +3883,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 245 - **Context:** `Unknown` -### UT-029 +### UT-030 **Locations:** 1 file(s) @@ -3850,14 +3895,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - -### UT-032 - -**Locations:** 1 file(s) - - **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - **Line:** 245 - **Context:** `Unknown` @@ -3870,12 +3907,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 245 - **Context:** `Unknown` -### UT-051 +### UT-044 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2900) - - **Line:** 2900 +- **File:** [`src-tauri/src/commands/offline.rs`](src-tauri/src/commands/offline.rs#L136) + - **Line:** 136 - **Context:** `Unknown` ### UT-052 @@ -3886,20 +3923,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 315 - **Context:** `Unknown` -### UT-059 - -**Locations:** 3 file(s) - -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) - - **Line:** 435 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2109) - - **Line:** 2109 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2150) - - **Line:** 2150 - - **Context:** `Unknown` - ### UT-060 **Locations:** 2 file(s) @@ -3968,14 +3991,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 7 - **Context:** `Unknown` -### UT-067 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2396) - - **Line:** 2396 - - **Context:** `Unknown` - ### UT-071 **Locations:** 1 file(s) @@ -4043,11 +4058,73 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, ### UT-092 -**Locations:** 1 file(s) +**Locations:** 3 file(s) - **File:** [`src/lib/components/player/VideoPlayer.tapSurface.test.ts`](src/lib/components/player/VideoPlayer.tapSurface.test.ts#L5) - **Line:** 5 - **Context:** `Unknown` +- **File:** [`src/lib/components/player/playerSurface.test.ts`](src/lib/components/player/playerSurface.test.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/playerSurface.ts`](src/lib/components/player/playerSurface.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` + +### UR-014 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/playlist.rs`](src-tauri/src/commands/playlist.rs#L4) + - **Line:** 4 + - **Context:** `Unknown` + +### UR-020 + +**Locations:** 4 file(s) + +- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) + - **Line:** 15 + - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19) + - **Line:** 19 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L1) + - **Line:** 1 + - **Context:** `Unknown` + +### UR-021 + +**Locations:** 5 file(s) + +- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) + - **Line:** 15 + - **Context:** `Unknown` +- **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19) + - **Line:** 19 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/storage/series_prefs.rs`](src-tauri/src/commands/storage/series_prefs.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UT-093 + +**Locations:** 2 file(s) + +- **File:** [`src/lib/components/player/playerSurface.test.ts`](src/lib/components/player/playerSurface.test.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/playerSurface.ts`](src/lib/components/player/playerSurface.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` ### UR-008 @@ -4211,43 +4288,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 2027 - **Context:** `Unknown` -### UR-020 - -**Locations:** 4 file(s) - -- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) - - **Line:** 1 - - **Context:** `Unknown` -- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) - - **Line:** 15 - - **Context:** `Unknown` -- **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19) - - **Line:** 19 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L1) - - **Line:** 1 - - **Context:** `Unknown` - -### UR-021 - -**Locations:** 5 file(s) - -- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) - - **Line:** 1 - - **Context:** `Unknown` -- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) - - **Line:** 15 - - **Context:** `Unknown` -- **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19) - - **Line:** 19 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L1) - - **Line:** 1 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/storage/series_prefs.rs`](src-tauri/src/commands/storage/series_prefs.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-024 **Locations:** 2 file(s) @@ -4259,59 +4299,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` -### UR-025 - -**Locations:** 16 file(s) - -- **File:** [`src/lib/utils/validation.test.ts`](src/lib/utils/validation.test.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.test.ts`](src/lib/services/playbackReporting.test.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L10) - - **Line:** 10 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L21) - - **Line:** 21 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L89) - - **Line:** 89 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L122) - - **Line:** 122 - - **Context:** `Unknown` -- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L7) - - **Line:** 7 - - **Context:** `Unknown` -- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L64) - - **Line:** 64 - - **Context:** `Unknown` -- **File:** [`src/lib/services/skipReporting.ts`](src/lib/services/skipReporting.ts#L14) - - **Line:** 14 - - **Context:** `Unknown` -- **File:** [`src/lib/services/skipReporting.test.ts`](src/lib/services/skipReporting.test.ts#L9) - - **Line:** 9 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L156) - - **Line:** 156 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/mod.rs`](src-tauri/src/commands/mod.rs#L2) - - **Line:** 2 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/playback_reporting.rs`](src-tauri/src/commands/playback_reporting.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/sync.rs`](src-tauri/src/commands/sync.rs#L5) - - **Line:** 5 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` - ### UR-030 **Locations:** 2 file(s) @@ -4356,6 +4343,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` +### UR-042 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + ### UR-043 **Locations:** 2 file(s) @@ -4367,6 +4362,22 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` +### UR-045 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/download/smart_cache.rs`](src-tauri/src/commands/download/smart_cache.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-047 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + ### UR-051 **Locations:** 2 file(s) @@ -4464,20 +4475,83 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 106 - **Context:** `Unknown` -### UT-042 +### IT-017 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) - - **Line:** 2027 +- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L441) + - **Line:** 441 - **Context:** `Unknown` -### UT-043 +### UT-014 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) + - **Line:** 311 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` + +### UT-018 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) + - **Line:** 311 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` + +### UT-022 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) + - **Line:** 311 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` + +### UT-028 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) - - **Line:** 2027 +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-032 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-051 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2900) + - **Line:** 2900 + - **Context:** `Unknown` + +### UT-059 + +**Locations:** 3 file(s) + +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) + - **Line:** 435 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2109) + - **Line:** 2109 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2150) + - **Line:** 2150 - **Context:** `Unknown` ### UT-066 @@ -4488,6 +4562,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 4 - **Context:** `Unknown` +### UT-067 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2396) + - **Line:** 2396 + - **Context:** `Unknown` + ### UT-072 **Locations:** 1 file(s) @@ -4576,6 +4658,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 337 - **Context:** `Unknown` +### UT-084 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L685) + - **Line:** 685 + - **Context:** `Unknown` + ### UT-091 **Locations:** 2 file(s) @@ -4587,6 +4677,25 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 15 - **Context:** `Unknown` +### UR-016 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L62) + - **Line:** 62 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) + - **Line:** 251 + - **Context:** `Unknown` + +### UR-022 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + ### UR-032 **Locations:** 7 file(s) @@ -4654,47 +4763,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 234 - **Context:** `Unknown` -### IT-003 +### UT-042 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) - - **Line:** 8 +- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) + - **Line:** 2027 - **Context:** `Unknown` -### UT-004 +### UT-043 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) - - **Line:** 535 - - **Context:** `pub enum AddPosition {` - -### UT-012 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) - - **Line:** 81 - - **Context:** `pub fn cancel(&mut self) {` - -### UR-016 - -**Locations:** 2 file(s) - -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L62) - - **Line:** 62 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251) - - **Line:** 251 - - **Context:** `Unknown` - -### UT-030 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 +- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) + - **Line:** 2027 - **Context:** `Unknown` ### UT-083 @@ -4705,22 +4787,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 673 - **Context:** `Unknown` -### UT-084 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L685) - - **Line:** 685 - - **Context:** `Unknown` - -### UR-022 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-044 **Locations:** 1 file(s) @@ -4729,14 +4795,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` -### UR-045 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/smart_cache.rs`](src-tauri/src/commands/download/smart_cache.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-046 **Locations:** 1 file(s) @@ -4745,28 +4803,4 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` -### UR-047 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - -### UT-044 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/offline.rs`](src-tauri/src/commands/offline.rs#L136) - - **Line:** 136 - - **Context:** `Unknown` - -### UR-042 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - diff --git a/scripts/extract-traces.test.ts b/scripts/extract-traces.test.ts index e8afaa96..68840ab5 100644 --- a/scripts/extract-traces.test.ts +++ b/scripts/extract-traces.test.ts @@ -175,8 +175,8 @@ describe("live requirements.md", () => { expect(defined.UR).toBe(61); expect(defined.IR).toBe(29); - expect(defined.DR).toBe(96); + expect(defined.DR).toBe(97); expect(defined.JA).toBe(32); - expect(defined.total).toBe(218); + expect(defined.total).toBe(219); }); }); diff --git a/src/lib/components/player/playerSurface.test.ts b/src/lib/components/player/playerSurface.test.ts new file mode 100644 index 00000000..0018d4c7 --- /dev/null +++ b/src/lib/components/player/playerSurface.test.ts @@ -0,0 +1,107 @@ +/** + * Regression tests for the `/player/[id]` surface decision. + * + * The bug these pin down: a video that was left and re-entered rendered in the + * AUDIO player. Exiting a webview-rendered video does not stop the Rust + * controller (`onReportStop` deliberately emits no `stopped` state, so the + * autoplay handoff survives), so the backend still reports that episode/movie as + * the loaded media. Re-entering the route therefore took the "already playing, + * just show the UI" shortcut, which returns *before* a stream URL is fetched — + * and the render then fell through to `` because it treated + * "video without a stream URL" as audio. + * + * TRACES: UR-005 | DR-100 | UT-092, UT-093 + */ + +import { describe, it, expect } from "vitest"; +import { shouldReuseActivePlayback, resolvePlayerSurface } from "./playerSurface"; + +describe("shouldReuseActivePlayback", () => { + it("reuses playback when the same audio track is already loaded", () => { + expect( + shouldReuseActivePlayback({ + requestedId: "track-1", + activeMediaId: "track-1", + isVideo: false, + forceRestart: false, + }) + ).toBe(true); + }); + + it("does NOT reuse playback for video, even when the backend reports it loaded", () => { + // Video needs a full load: the shortcut skips fetching the stream URL, and + // cannot render without one. + expect( + shouldReuseActivePlayback({ + requestedId: "episode-1", + activeMediaId: "episode-1", + isVideo: true, + forceRestart: false, + }) + ).toBe(false); + }); + + it("does not reuse playback for a different item", () => { + expect( + shouldReuseActivePlayback({ + requestedId: "track-2", + activeMediaId: "track-1", + isVideo: false, + forceRestart: false, + }) + ).toBe(false); + }); + + it("does not reuse playback when nothing is loaded", () => { + expect( + shouldReuseActivePlayback({ + requestedId: "track-1", + activeMediaId: null, + isVideo: false, + forceRestart: false, + }) + ).toBe(false); + }); + + it("does not reuse playback when an explicit start position is requested", () => { + expect( + shouldReuseActivePlayback({ + requestedId: "track-1", + activeMediaId: "track-1", + isVideo: false, + startPosition: 42, + forceRestart: false, + }) + ).toBe(false); + }); + + it("does not reuse playback when restarting (next-episode advance)", () => { + expect( + shouldReuseActivePlayback({ + requestedId: "episode-2", + activeMediaId: "episode-2", + isVideo: true, + forceRestart: true, + }) + ).toBe(false); + }); +}); + +describe("resolvePlayerSurface", () => { + it("renders the video surface for video with a stream URL", () => { + expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe( + "video" + ); + }); + + it("renders the audio surface for audio content", () => { + expect(resolvePlayerSurface({ isVideo: false, streamUrl: null })).toBe("audio"); + }); + + it("never renders video content in the audio surface when the stream URL is missing", () => { + // A video whose stream URL has not resolved yet is pending, not audio — + // otherwise the movie/episode shows up in the audio player. + expect(resolvePlayerSurface({ isVideo: true, streamUrl: null })).toBe("pending"); + expect(resolvePlayerSurface({ isVideo: true, streamUrl: "" })).toBe("pending"); + }); +}); diff --git a/src/lib/components/player/playerSurface.ts b/src/lib/components/player/playerSurface.ts new file mode 100644 index 00000000..0e786c78 --- /dev/null +++ b/src/lib/components/player/playerSurface.ts @@ -0,0 +1,63 @@ +/** + * Pure decisions for the `/player/[id]` route: which player surface to render, + * and whether a load can be skipped because the backend is already playing the + * requested item. + * + * Kept free of Svelte so both can be unit-tested without mounting the route. + * + * TRACES: UR-005 | DR-100 | UT-092, UT-093 + */ + +/** Which player component the route should render. */ +export type PlayerSurface = "video" | "audio" | "pending"; + +export interface ReuseActivePlaybackInput { + /** Item id the route was asked to play. */ + requestedId: string; + /** Id of the media the backend currently reports as loaded, if any. */ + activeMediaId: string | null | undefined; + /** Whether the requested item is video content. */ + isVideo: boolean; + /** Explicit start position, if the caller asked for one. */ + startPosition?: number; + /** Advancing to a next episode always restarts from the beginning. */ + forceRestart: boolean; +} + +/** + * Whether the route can show its UI over the backend's existing playback + * instead of reloading the item (e.g. expanding the audio mini player). + * + * Never for video. The shortcut returns before a stream URL is fetched, which + * is fine for audio (the backend owns the stream and the UI only mirrors it) + * but leaves `` with nothing to render. Leaving a webview-rendered + * video does not clear the Rust controller's media — closing the route emits no + * `stopped` state by design — so re-entering the same movie/episode hit this + * shortcut and rendered the audio player instead. + */ +export function shouldReuseActivePlayback(input: ReuseActivePlaybackInput): boolean { + return ( + !input.isVideo && + input.activeMediaId === input.requestedId && + !input.startPosition && + !input.forceRestart + ); +} + +export interface PlayerSurfaceInput { + isVideo: boolean; + streamUrl: string | null; +} + +/** + * Which surface to render for the loaded item. + * + * Video without a stream URL is `pending`, never `audio` — falling through to + * the audio player is how a movie/episode ended up in it. + */ +export function resolvePlayerSurface(input: PlayerSurfaceInput): PlayerSurface { + if (input.isVideo) { + return input.streamUrl ? "video" : "pending"; + } + return "audio"; +} diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index 64a6a1fd..27c0d4da 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -14,6 +14,7 @@ import { get } from "svelte/store"; import AudioPlayer from "$lib/components/player/AudioPlayer.svelte"; import VideoPlayer from "$lib/components/player/VideoPlayer.svelte"; + import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface"; import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte"; import { reportPlaybackStart, @@ -72,6 +73,10 @@ let pollInterval: ReturnType | null = null; let loadedItemId: string | null = null; + // Which player component to render. Video without a stream URL is "pending" + // (still resolving), never audio — see playerSurface.ts. + const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl })); + onMount(() => { // Start position polling (only for audio via MPV backend) pollInterval = setInterval(updateStatus, 1000); @@ -137,30 +142,33 @@ return; } - // If this track is already playing in the backend, just show the UI - // without restarting playback (e.g., when expanding from MiniPlayer). - // forceRestart bypasses this so advancing to the next episode always - // restarts from the beginning even if it were already loaded. - const alreadyPlayingMedia = get(storeCurrentMedia); - if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) { - console.log("loadAndPlay: Track already playing, showing UI without restarting"); - isLive = item.kind === "liveChannel"; - isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item); - isPlaying = true; - loading = false; - // hasNext/hasPrevious come from the event-driven queue store. - // Fetch next episode for video skip button - if (isVideo) { - fetchNextEpisode(item); - } - return; - } - // Determine if this is video content (Movie, Episode, live TV channels, and // channel leaf items that carry a video stream). isLive = item.kind === "liveChannel"; isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item); + // If this track is already playing in the backend, just show the UI + // without restarting playback (e.g., when expanding from MiniPlayer). + // Audio only, and forceRestart bypasses it so advancing to the next + // episode always restarts from the beginning — see playerSurface.ts for + // why video must never take this shortcut. + const alreadyPlayingMedia = get(storeCurrentMedia); + if ( + shouldReuseActivePlayback({ + requestedId: id, + activeMediaId: alreadyPlayingMedia?.id, + isVideo, + startPosition, + forceRestart, + }) + ) { + console.log("loadAndPlay: Track already playing, showing UI without restarting"); + isPlaying = true; + loading = false; + // hasNext/hasPrevious come from the event-driven queue store. + return; + } + // When switching to video, stop audio playback and clear the queue // This prevents audio from continuing in the background and clears stale state if (isVideo) { @@ -650,10 +658,6 @@ -{:else if loading} -
-
-
{:else if error}
@@ -667,7 +671,13 @@
-{:else if isVideo && streamUrl} +{:else if loading || surface === "pending"} + +
+
+
+{:else if surface === "video" && streamUrl}