fix(player): render subtitle tracks on the Linux HTML5 path (UR-020)

Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.

The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.

Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.

Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).

Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.

Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.

Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.

Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.

TRACES: UR-020 | DR-023 | UT-143, UT-144
This commit is contained in:
2026-08-11 19:25:39 +02:00
parent 2c3955914e
commit 211792947d
5 changed files with 755 additions and 307 deletions
+2
View File
@@ -542,6 +542,8 @@ Internal architecture, components, and application logic.
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done | | UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done | | UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done | | UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
### Integration Tests ### Integration Tests
+328 -276
View File
@@ -1,11 +1,11 @@
# Code Traceability Matrix # Code Traceability Matrix
**Generated:** 8/11/2026, 7:10:18 PM **Generated:** 8/11/2026, 7:25:20 PM
## Summary ## Summary
- **Total Files Scanned:** 339 - **Total Files Scanned:** 341
- **Total TRACES Found:** 579 - **Total TRACES Found:** 583
- **Requirements Covered:** - **Requirements Covered:**
- User Requirements (UR): 67 - User Requirements (UR): 67
- Integration Requirements (IR): 17 - Integration Requirements (IR): 17
@@ -700,11 +700,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
### DR-023 ### DR-023
**Locations:** 3 file(s) **Locations:** 7 file(s)
- **File:** [`src/lib/components/player/subtitleTracks.ts`](src/lib/components/player/subtitleTracks.ts#L15)
- **Line:** 15
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/subtitleTracks.test.ts`](src/lib/components/player/subtitleTracks.test.ts#L16)
- **Line:** 16
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) - **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
- **Line:** 1 - **Line:** 1
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L291)
- **Line:** 291
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1729)
- **Line:** 1729
- **Context:** `function toggleSubtitleMenu() {`
- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) - **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15)
- **Line:** 15 - **Line:** 15
- **Context:** `Unknown` - **Context:** `Unknown`
@@ -2234,8 +2246,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L235) - **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L235)
- **Line:** 235 - **Line:** 235
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1873) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1874)
- **Line:** 1873 - **Line:** 1874
- **Context:** `Unknown` - **Context:** `Unknown`
### DR-107 ### DR-107
@@ -2374,11 +2386,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L808) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L808)
- **Line:** 808 - **Line:** 808
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2691) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2692)
- **Line:** 2691 - **Line:** 2692
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2722) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2723)
- **Line:** 2722 - **Line:** 2723
- **Context:** `Unknown` - **Context:** `Unknown`
### DR-114 ### DR-114
@@ -2432,17 +2444,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683)
- **Line:** 683 - **Line:** 683
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1813) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1814)
- **Line:** 1813 - **Line:** 1814
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2601) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2602)
- **Line:** 2601 - **Line:** 2602
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2623) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2624)
- **Line:** 2623 - **Line:** 2624
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2632) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2633)
- **Line:** 2632 - **Line:** 2633
- **Context:** `Unknown` - **Context:** `Unknown`
### DR-116 ### DR-116
@@ -2479,8 +2491,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L661) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L661)
- **Line:** 661 - **Line:** 661
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2654) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2655)
- **Line:** 2654 - **Line:** 2655
- **Context:** `Unknown` - **Context:** `Unknown`
### DR-117 ### DR-117
@@ -2816,8 +2828,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L243) - **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L243)
- **Line:** 243 - **Line:** 243
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1914) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1915)
- **Line:** 1914 - **Line:** 1915
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs#L1346) - **File:** [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs#L1346)
- **Line:** 1346 - **Line:** 1346
@@ -3386,11 +3398,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L452) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L452)
- **Line:** 452 - **Line:** 452
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2374) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2375)
- **Line:** 2374 - **Line:** 2375
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2415) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2416)
- **Line:** 2415 - **Line:** 2416
- **Context:** `Unknown` - **Context:** `Unknown`
### JA-033 ### JA-033
@@ -3409,14 +3421,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683)
- **Line:** 683 - **Line:** 683
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1813) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1814)
- **Line:** 1813 - **Line:** 1814
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1873) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1874)
- **Line:** 1873 - **Line:** 1874
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2601) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2602)
- **Line:** 2601 - **Line:** 2602
- **Context:** `Unknown` - **Context:** `Unknown`
### JA-034 ### JA-034
@@ -3429,8 +3441,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L808) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L808)
- **Line:** 808 - **Line:** 808
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2691) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2692)
- **Line:** 2691 - **Line:** 2692
- **Context:** `Unknown` - **Context:** `Unknown`
### JA-035 ### JA-035
@@ -3440,8 +3452,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L243) - **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L243)
- **Line:** 243 - **Line:** 243
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1914) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1915)
- **Line:** 1914 - **Line:** 1915
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-007 ### UR-007
@@ -3698,8 +3710,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L243) - **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L243)
- **Line:** 243 - **Line:** 243
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1914) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1915)
- **Line:** 1914 - **Line:** 1915
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs#L1346) - **File:** [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs#L1346)
- **Line:** 1346 - **Line:** 1346
@@ -4048,11 +4060,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L452) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L452)
- **Line:** 452 - **Line:** 452
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2374) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2375)
- **Line:** 2374 - **Line:** 2375
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2415) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2416)
- **Line:** 2415 - **Line:** 2416
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-048 ### UR-048
@@ -4608,20 +4620,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683)
- **Line:** 683 - **Line:** 683
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1813) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1814)
- **Line:** 1813 - **Line:** 1814
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2601) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2602)
- **Line:** 2601 - **Line:** 2602
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2623) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2624)
- **Line:** 2623 - **Line:** 2624
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2632) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2633)
- **Line:** 2632 - **Line:** 2633
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2654) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2655)
- **Line:** 2654 - **Line:** 2655
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-068 ### UR-068
@@ -4919,22 +4931,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) **Locations:** 1 file(s)
- **File:** [`src/lib/utils/safeArea.test.ts`](src/lib/utils/safeArea.test.ts#L17)
- **Line:** 17
- **Context:** `Unknown`
### UT-096
**Locations:** 1 file(s)
- **File:** [`src/lib/utils/safeArea.test.ts`](src/lib/utils/safeArea.test.ts#L17)
- **Line:** 17
- **Context:** `Unknown`
### UT-097
**Locations:** 1 file(s)
- **File:** [`src/lib/utils/safeArea.test.ts`](src/lib/utils/safeArea.test.ts#L17) - **File:** [`src/lib/utils/safeArea.test.ts`](src/lib/utils/safeArea.test.ts#L17)
- **Line:** 17 - **Line:** 17
- **Context:** `Unknown` - **Context:** `Unknown`
@@ -5062,11 +5058,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L808) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L808)
- **Line:** 808 - **Line:** 808
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2691) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2692)
- **Line:** 2691 - **Line:** 2692
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2722) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2723)
- **Line:** 2722 - **Line:** 2723
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs#L1339) - **File:** [`src-tauri/src/lib.rs`](src-tauri/src/lib.rs#L1339)
- **Line:** 1339 - **Line:** 1339
@@ -5938,11 +5934,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
### UR-020 ### UR-020
**Locations:** 4 file(s) **Locations:** 8 file(s)
- **File:** [`src/lib/components/player/subtitleTracks.ts`](src/lib/components/player/subtitleTracks.ts#L15)
- **Line:** 15
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/subtitleTracks.test.ts`](src/lib/components/player/subtitleTracks.test.ts#L16)
- **Line:** 16
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) - **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
- **Line:** 1 - **Line:** 1
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L291)
- **Line:** 291
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1729)
- **Line:** 1729
- **Context:** `function toggleSubtitleMenu() {`
- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15) - **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15)
- **Line:** 15 - **Line:** 15
- **Context:** `Unknown` - **Context:** `Unknown`
@@ -6111,8 +6119,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L235) - **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L235)
- **Line:** 235 - **Line:** 235
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1873) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1874)
- **Line:** 1873 - **Line:** 1874
- **Context:** `Unknown` - **Context:** `Unknown`
### UT-085 ### UT-085
@@ -6203,33 +6211,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L661) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L661)
- **Line:** 661 - **Line:** 661
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2654) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2655)
- **Line:** 2654 - **Line:** 2655
- **Context:** `Unknown`
### UR-036
**Locations:** 3 file(s)
- **File:** [`src/lib/components/library/PersonDetailView.svelte`](src/lib/components/library/PersonDetailView.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/storage/people.rs`](src-tauri/src/commands/storage/people.rs#L3)
- **Line:** 3
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L4)
- **Line:** 4
- **Context:** `Unknown`
### UR-051
**Locations:** 2 file(s)
- **File:** [`src/lib/components/library/LibraryGrid.svelte`](src/lib/components/library/LibraryGrid.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
- **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L1)
- **Line:** 1
- **Context:** `Unknown` - **Context:** `Unknown`
### UT-118 ### UT-118
@@ -6329,6 +6312,66 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 1 - **Line:** 1
- **Context:** `Unknown` - **Context:** `Unknown`
### UT-137
**Locations:** 1 file(s)
- **File:** [`src/lib/services/offlineCatalog.reload.test.ts`](src/lib/services/offlineCatalog.reload.test.ts#L11)
- **Line:** 11
- **Context:** `Unknown`
### UT-138
**Locations:** 1 file(s)
- **File:** [`src/lib/services/offlineCatalog.reload.test.ts`](src/lib/services/offlineCatalog.reload.test.ts#L11)
- **Line:** 11
- **Context:** `Unknown`
### UT-139
**Locations:** 1 file(s)
- **File:** [`src/lib/services/offlineCatalog.reload.test.ts`](src/lib/services/offlineCatalog.reload.test.ts#L11)
- **Line:** 11
- **Context:** `Unknown`
### UT-140
**Locations:** 1 file(s)
- **File:** [`src/lib/composables/useOfflineFilterReload.test.ts`](src/lib/composables/useOfflineFilterReload.test.ts#L1)
- **Line:** 1
- **Context:** `Unknown`
### UT-143
**Locations:** 3 file(s)
- **File:** [`src/lib/components/player/subtitleTracks.ts`](src/lib/components/player/subtitleTracks.ts#L15)
- **Line:** 15
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/subtitleTracks.test.ts`](src/lib/components/player/subtitleTracks.test.ts#L16)
- **Line:** 16
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L291)
- **Line:** 291
- **Context:** `Unknown`
### UT-144
**Locations:** 3 file(s)
- **File:** [`src/lib/components/player/subtitleTracks.ts`](src/lib/components/player/subtitleTracks.ts#L15)
- **Line:** 15
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/subtitleTracks.test.ts`](src/lib/components/player/subtitleTracks.test.ts#L16)
- **Line:** 16
- **Context:** `Unknown`
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L291)
- **Line:** 291
- **Context:** `Unknown`
### UR-010 ### UR-010
**Locations:** 15 file(s) **Locations:** 15 file(s)
@@ -6422,6 +6465,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 2 - **Line:** 2
- **Context:** `Unknown` - **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-015 ### UR-015
**Locations:** 13 file(s) **Locations:** 13 file(s)
@@ -6466,6 +6517,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 3 - **Line:** 3
- **Context:** `Unknown` - **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-018 ### UR-018
**Locations:** 3 file(s) **Locations:** 3 file(s)
@@ -6480,6 +6542,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 2040 - **Line:** 2040
- **Context:** `Unknown` - **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-024 ### UR-024
**Locations:** 2 file(s) **Locations:** 2 file(s)
@@ -6502,164 +6572,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 2 - **Line:** 2
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-039
**Locations:** 1 file(s)
- **File:** [`src/lib/components/BottomNav.svelte`](src/lib/components/BottomNav.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
### UR-041
**Locations:** 1 file(s)
- **File:** [`src/lib/utils/pictureInPicture.ts`](src/lib/utils/pictureInPicture.ts#L4)
- **Line:** 4
- **Context:** `Unknown`
### UR-043
**Locations:** 2 file(s)
- **File:** [`src/lib/stores/connectivity.ts`](src/lib/stores/connectivity.ts#L10)
- **Line:** 10
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/connectivity.rs`](src-tauri/src/commands/connectivity.rs#L3)
- **Line:** 3
- **Context:** `Unknown`
### UR-054
**Locations:** 6 file(s)
- **File:** [`src/lib/components/search/HeaderSearch.svelte`](src/lib/components/search/HeaderSearch.svelte#L12)
- **Line:** 12
- **Context:** `Unknown`
- **File:** [`src/lib/components/search/HeaderSearch.test.ts`](src/lib/components/search/HeaderSearch.test.ts#L9)
- **Line:** 9
- **Context:** `Unknown`
- **File:** [`src/lib/components/account/AccountMenu.svelte`](src/lib/components/account/AccountMenu.svelte#L7)
- **Line:** 7
- **Context:** `Unknown`
- **File:** [`src/lib/components/AppHeader.svelte`](src/lib/components/AppHeader.svelte#L10)
- **Line:** 10
- **Context:** `Unknown`
- **File:** [`src/lib/utils/layoutShell.ts`](src/lib/utils/layoutShell.ts#L82)
- **Line:** 82
- **Context:** `Unknown`
- **File:** [`src/lib/utils/layoutShell.ts`](src/lib/utils/layoutShell.ts#L104)
- **Line:** 104
- **Context:** `Unknown`
### UR-059
**Locations:** 6 file(s)
- **File:** [`src/lib/stores/continueWatchingFilter.ts`](src/lib/stores/continueWatchingFilter.ts#L12)
- **Line:** 12
- **Context:** `Unknown`
- **File:** [`src/lib/stores/continueWatchingFilter.test.ts`](src/lib/stores/continueWatchingFilter.test.ts#L9)
- **Line:** 9
- **Context:** `Unknown`
- **File:** [`src/lib/stores/tv.ts`](src/lib/stores/tv.ts#L3)
- **Line:** 3
- **Context:** `Unknown`
- **File:** [`src/lib/stores/home.ts`](src/lib/stores/home.ts#L2)
- **Line:** 2
- **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`
### UR-060
**Locations:** 7 file(s)
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L155)
- **Line:** 155
- **Context:** `Unknown`
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L221)
- **Line:** 221
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L96)
- **Line:** 96
- **Context:** `Unknown`
- **File:** [`src-tauri/src/storage/schema.rs`](src-tauri/src/storage/schema.rs#L769)
- **Line:** 769
- **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L333)
- **Line:** 333
- **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L3017)
- **Line:** 3017
- **Context:** `Unknown`
- **File:** [`src-tauri/src/domain/search_rank.rs`](src-tauri/src/domain/search_rank.rs#L106)
- **Line:** 106
- **Context:** `Unknown`
### UT-137
**Locations:** 1 file(s)
- **File:** [`src/lib/services/offlineCatalog.reload.test.ts`](src/lib/services/offlineCatalog.reload.test.ts#L11)
- **Line:** 11
- **Context:** `Unknown`
### UT-138
**Locations:** 1 file(s)
- **File:** [`src/lib/services/offlineCatalog.reload.test.ts`](src/lib/services/offlineCatalog.reload.test.ts#L11)
- **Line:** 11
- **Context:** `Unknown`
### UT-139
**Locations:** 1 file(s)
- **File:** [`src/lib/services/offlineCatalog.reload.test.ts`](src/lib/services/offlineCatalog.reload.test.ts#L11)
- **Line:** 11
- **Context:** `Unknown`
### UT-140
**Locations:** 1 file(s)
- **File:** [`src/lib/composables/useOfflineFilterReload.test.ts`](src/lib/composables/useOfflineFilterReload.test.ts#L1)
- **Line:** 1
- **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-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-031 ### UR-031
**Locations:** 2 file(s) **Locations:** 2 file(s)
@@ -6738,6 +6650,36 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 234 - **Line:** 234
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-036
**Locations:** 3 file(s)
- **File:** [`src/lib/components/library/PersonDetailView.svelte`](src/lib/components/library/PersonDetailView.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/storage/people.rs`](src-tauri/src/commands/storage/people.rs#L3)
- **Line:** 3
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L4)
- **Line:** 4
- **Context:** `Unknown`
### UR-039
**Locations:** 1 file(s)
- **File:** [`src/lib/components/BottomNav.svelte`](src/lib/components/BottomNav.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
### UR-041
**Locations:** 1 file(s)
- **File:** [`src/lib/utils/pictureInPicture.ts`](src/lib/utils/pictureInPicture.ts#L4)
- **Line:** 4
- **Context:** `Unknown`
### UR-042 ### UR-042
**Locations:** 1 file(s) **Locations:** 1 file(s)
@@ -6746,6 +6688,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 3 - **Line:** 3
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-043
**Locations:** 2 file(s)
- **File:** [`src/lib/stores/connectivity.ts`](src/lib/stores/connectivity.ts#L10)
- **Line:** 10
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/connectivity.rs`](src-tauri/src/commands/connectivity.rs#L3)
- **Line:** 3
- **Context:** `Unknown`
### UR-044 ### UR-044
**Locations:** 1 file(s) **Locations:** 1 file(s)
@@ -6778,6 +6731,105 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **Line:** 3 - **Line:** 3
- **Context:** `Unknown` - **Context:** `Unknown`
### UR-051
**Locations:** 2 file(s)
- **File:** [`src/lib/components/library/LibraryGrid.svelte`](src/lib/components/library/LibraryGrid.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
- **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L1)
- **Line:** 1
- **Context:** `Unknown`
### UR-054
**Locations:** 6 file(s)
- **File:** [`src/lib/components/search/HeaderSearch.svelte`](src/lib/components/search/HeaderSearch.svelte#L12)
- **Line:** 12
- **Context:** `Unknown`
- **File:** [`src/lib/components/search/HeaderSearch.test.ts`](src/lib/components/search/HeaderSearch.test.ts#L9)
- **Line:** 9
- **Context:** `Unknown`
- **File:** [`src/lib/components/account/AccountMenu.svelte`](src/lib/components/account/AccountMenu.svelte#L7)
- **Line:** 7
- **Context:** `Unknown`
- **File:** [`src/lib/components/AppHeader.svelte`](src/lib/components/AppHeader.svelte#L10)
- **Line:** 10
- **Context:** `Unknown`
- **File:** [`src/lib/utils/layoutShell.ts`](src/lib/utils/layoutShell.ts#L82)
- **Line:** 82
- **Context:** `Unknown`
- **File:** [`src/lib/utils/layoutShell.ts`](src/lib/utils/layoutShell.ts#L104)
- **Line:** 104
- **Context:** `Unknown`
### UR-059
**Locations:** 6 file(s)
- **File:** [`src/lib/stores/continueWatchingFilter.ts`](src/lib/stores/continueWatchingFilter.ts#L12)
- **Line:** 12
- **Context:** `Unknown`
- **File:** [`src/lib/stores/continueWatchingFilter.test.ts`](src/lib/stores/continueWatchingFilter.test.ts#L9)
- **Line:** 9
- **Context:** `Unknown`
- **File:** [`src/lib/stores/tv.ts`](src/lib/stores/tv.ts#L3)
- **Line:** 3
- **Context:** `Unknown`
- **File:** [`src/lib/stores/home.ts`](src/lib/stores/home.ts#L2)
- **Line:** 2
- **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`
### UT-096
**Locations:** 1 file(s)
- **File:** [`src/lib/utils/safeArea.test.ts`](src/lib/utils/safeArea.test.ts#L17)
- **Line:** 17
- **Context:** `Unknown`
### UT-097
**Locations:** 1 file(s)
- **File:** [`src/lib/utils/safeArea.test.ts`](src/lib/utils/safeArea.test.ts#L17)
- **Line:** 17
- **Context:** `Unknown`
### UR-060
**Locations:** 7 file(s)
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L155)
- **Line:** 155
- **Context:** `Unknown`
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L221)
- **Line:** 221
- **Context:** `Unknown`
- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L96)
- **Line:** 96
- **Context:** `Unknown`
- **File:** [`src-tauri/src/storage/schema.rs`](src-tauri/src/storage/schema.rs#L769)
- **Line:** 769
- **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L333)
- **Line:** 333
- **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L3017)
- **Line:** 3017
- **Context:** `Unknown`
- **File:** [`src-tauri/src/domain/search_rank.rs`](src-tauri/src/domain/search_rank.rs#L106)
- **Line:** 106
- **Context:** `Unknown`
### UR-065 ### UR-065
**Locations:** 22 file(s) **Locations:** 22 file(s)
@@ -7009,11 +7061,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L452) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L452)
- **Line:** 452 - **Line:** 452
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2374) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2375)
- **Line:** 2374 - **Line:** 2375
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2415) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2416)
- **Line:** 2415 - **Line:** 2416
- **Context:** `Unknown` - **Context:** `Unknown`
### UT-067 ### UT-067
@@ -7119,11 +7171,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L592) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L592)
- **Line:** 592 - **Line:** 592
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2691) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2692)
- **Line:** 2691 - **Line:** 2692
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2722) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2723)
- **Line:** 2722 - **Line:** 2723
- **Context:** `Unknown` - **Context:** `Unknown`
### UT-100 ### UT-100
@@ -7136,17 +7188,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L683)
- **Line:** 683 - **Line:** 683
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1813) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1814)
- **Line:** 1813 - **Line:** 1814
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2601) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2602)
- **Line:** 2601 - **Line:** 2602
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2623) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2624)
- **Line:** 2623 - **Line:** 2624
- **Context:** `Unknown` - **Context:** `Unknown`
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2632) - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2633)
- **Line:** 2632 - **Line:** 2633
- **Context:** `Unknown` - **Context:** `Unknown`
### UT-101 ### UT-101
+94 -31
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 --> <!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, untrack } from "svelte"; import { onMount, onDestroy, tick, untrack } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
@@ -14,6 +14,12 @@
import SleepTimerIndicator from "./SleepTimerIndicator.svelte"; import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit"; import { videoFitClass } from "./videoFit";
import {
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
type RenderableSubtitleTrack,
} from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition, playerState } from "$lib/stores/player"; import { playbackPosition, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter"; import * as html5Adapter from "$lib/player/html5Adapter";
@@ -275,6 +281,52 @@
return tracks; return tracks;
}); });
// ===== Subtitle <track> sources for the HTML5 element (Linux/WebKitGTK) =====
// Resolved asynchronously into state and only then rendered. The URLs come
// from an async command, so they must never be bound to `src` directly — the
// original markup did exactly that and put "[object Promise]" on every track,
// which is why the whole block ended up commented out (and why selecting a
// subtitle did nothing: with no <track> children the element has no
// textTracks for the adapter to switch on).
// TRACES: UR-020 | DR-023 | UT-143, UT-144
let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
// Cross-origin <track> fetches use the media element's CORS setting; see
// videoCrossOriginMode for why this is opt-in and same-origin-only.
const videoCrossOrigin = $derived(
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
);
$effect(() => {
const streams = media?.mediaStreams ?? null;
const itemId = media?.id;
const sourceId = mediaSourceId;
// Native (ExoPlayer) mode renders subtitles itself; the element has none.
if (!useHtml5Element || !itemId || !sourceId) {
renderedSubtitleTracks = [];
return;
}
let cancelled = false;
void (async () => {
const tracks = await resolveSubtitleTracks(streams, (index) => getSubtitleUrl(index));
if (cancelled) return;
renderedSubtitleTracks = tracks;
// Keep the menu's checkmark and the element's text tracks in agreement:
// a selection that no longer resolves collapses to "Off".
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
selectedSubtitleIndex = selected;
// The <track> children were just (re)created, so re-apply the selection to
// the new TextTrack objects — otherwise a surviving selection shows nothing.
await tick();
if (!cancelled) applySubtitleToElement(selected);
})();
return () => {
cancelled = true;
};
});
// Track the last prop value to detect when parent changes the URL (vs internal seeks) // Track the last prop value to detect when parent changes the URL (vs internal seeks)
let lastStreamUrlProp = $state(""); let lastStreamUrlProp = $state("");
@@ -1670,34 +1722,40 @@
showSubtitleMenu = !showSubtitleMenu; showSubtitleMenu = !showSubtitleMenu;
} }
/**
* Show exactly one (or no) text track on the HTML5 element. `null` disables
* every track, which is what the menu's "Off" entry means.
*
* TRACES: UR-020 | DR-023
*/
function applySubtitleToElement(streamIndex: number | null) {
if (!useHtml5Element || !videoElement || !videoElement.textTracks) return;
// Disable all text tracks first, so "Off" genuinely turns subtitles off.
for (let i = 0; i < videoElement.textTracks.length; i++) {
videoElement.textTracks[i].mode = "disabled";
}
if (streamIndex === null) return;
// Find the corresponding track element by stream index.
videoElement.querySelectorAll("track").forEach((track) => {
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
if (trackStreamIndex === streamIndex && track.track) {
track.track.mode = "showing";
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
}
});
}
async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) { async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) {
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex); console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
selectedSubtitleIndex = streamIndex; selectedSubtitleIndex = streamIndex;
showSubtitleMenu = false; showSubtitleMenu = false;
// For HTML5 video element, update the text tracks // For HTML5 video element, update the text tracks
if (useHtml5Element && videoElement && videoElement.textTracks) { if (useHtml5Element) {
// Disable all text tracks first applySubtitleToElement(streamIndex);
for (let i = 0; i < videoElement.textTracks.length; i++) { } else {
videoElement.textTracks[i].mode = "disabled";
}
// Enable the selected track if not null
if (streamIndex !== null) {
// Find the corresponding track element by stream index
const tracks = videoElement.querySelectorAll("track");
tracks.forEach((track) => {
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
if (trackStreamIndex === streamIndex) {
const textTrack = track.track;
if (textTrack) {
textTrack.mode = "showing";
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
}
}
});
}
} else if (!useHtml5Element) {
// For native backend (Android), send command to change subtitle track // For native backend (Android), send command to change subtitle track
try { try {
// Use array index for ExoPlayer (0-based position in subtitle tracks array) // Use array index for ExoPlayer (0-based position in subtitle tracks array)
@@ -1743,6 +1801,7 @@
<video <video
bind:this={videoElement} bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl} src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()} class={videoFitClass()}
class:invisible={!isMediaReady} class:invisible={!isMediaReady}
style="filter: brightness({brightness})" style="filter: brightness({brightness})"
@@ -1761,18 +1820,22 @@
onloadstart={handleLoadStart} onloadstart={handleLoadStart}
onclick={handleSurfaceClick} onclick={handleSurfaceClick}
> >
<!-- Temporarily disabled to debug playback issues <!--
{#each subtitleTracks() as track} Subtitles for the HTML5 path. `src` is a resolved string (see
renderedSubtitleTracks); `data-stream-index` is what
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
attribute: a default track auto-shows, which would contradict the
menu opening on "Off".
-->
{#each renderedSubtitleTracks as track (track.streamIndex)}
<track <track
kind="subtitles" kind="subtitles"
src={getSubtitleUrl(track.index)} src={track.url}
srclang={track.language || "unknown"} srclang={track.srclang}
label={track.displayTitle || track.language || `Track ${track.index}`} label={track.label}
data-stream-index={track.index} data-stream-index={track.streamIndex}
default={track.isDefault}
/> />
{/each} {/each}
-->
</video> </video>
{:else} {:else}
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView --> <!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
@@ -0,0 +1,180 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
subtitleStreamsOf,
subtitleTrackLabel,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
type SubtitleStreamLike,
} from "./subtitleTracks";
/**
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
*
* TRACES: UR-020 | DR-023 | UT-143, UT-144
*
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
* block was commented out "to debug playback issues"), so
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
* the subtitle menu was inert on Linux. The reason it had to be disabled is
* visible in the original markup `src={getSubtitleUrl(track.index)}` bound the
* *Promise* returned by an async function to the attribute, so every track's src
* stringified to "[object Promise]", an unloadable resource hanging off the
* media element.
*
* So the fix has two halves and both are tested here: URLs must be resolved into
* plain strings *before* they reach the markup, and the markup must actually
* render the tracks (with the `data-stream-index` the adapter matches on).
*/
const SUBS: SubtitleStreamLike[] = [
{ index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
{ index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
];
const STREAMS: SubtitleStreamLike[] = [
{ index: 0, kind: "video", language: null, displayTitle: "1080p" },
{ index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
...SUBS,
];
const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;
describe("subtitleStreamsOf", () => {
it("keeps only subtitle streams, in stream order", () => {
expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
});
it("tolerates missing media streams", () => {
expect(subtitleStreamsOf(null)).toEqual([]);
expect(subtitleStreamsOf(undefined)).toEqual([]);
});
});
describe("subtitleTrackLabel", () => {
it("prefers the display title, then language, then the index", () => {
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
});
});
describe("resolveSubtitleTracks", () => {
it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
expect(tracks).toHaveLength(2);
for (const track of tracks) {
expect(typeof track.url).toBe("string");
// The exact regression: a Promise bound to src stringifies to this.
expect(String(track.url)).not.toContain("[object Promise]");
expect(track.url).toContain("subtitles.vtt");
}
// The adapter matches <track> elements by data-stream-index, so the stream
// index has to survive resolution.
expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
expect(tracks[0].isDefault).toBe(true);
});
it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
if (i === 2) throw new Error("no repository");
return url(i);
});
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
});
it("drops empty and non-string URLs", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
i === 2 ? " " : (undefined as unknown as string),
);
expect(tracks).toEqual([]);
});
it("returns nothing when there are no subtitle streams", async () => {
expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
});
});
describe("reconcileSelectedSubtitle", () => {
it("starts off (null) and keeps 'off' selectable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
it("keeps a selection that is still renderable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
});
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
});
it("never auto-selects the server's default track", async () => {
// The menu opens on "Off" and a <track default> would auto-show, so the UI
// would claim subtitles are off while they are burned over the picture.
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(tracks[0].isDefault).toBe(true);
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
});
describe("videoCrossOriginMode", () => {
it("opts into CORS for a server stream that has subtitles", () => {
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
});
it("leaves a local/offline source alone so playback cannot regress", () => {
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
});
it("stays out of the way when there is nothing to load", () => {
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
expect(videoCrossOriginMode("", 0)).toBeUndefined();
});
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
// Same answer before and after the async URL resolution completes.
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
expect(before).toBe(after);
});
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(
resolve(__dirname, "VideoPlayer.svelte"),
"utf-8",
);
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
expect(source).toMatch(/<track\b/);
expect(source).toContain('kind="subtitles"');
});
/** The rendered element, not a `<track>` mentioned in prose. */
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
expect(trackElement).toContain("data-stream-index");
});
it("never binds the async getSubtitleUrl() Promise to src", () => {
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
});
it("does not mark any track default (a default track auto-shows)", () => {
expect(trackElement).not.toMatch(/\bdefault=/);
});
});
+151
View File
@@ -0,0 +1,151 @@
// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
//
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
// attribute and every `<track>` pointed at "[object Promise]". The whole block
// was commented out rather than fixed, which left `<video>` with no text tracks
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
// `textTracks` list and the subtitle menu silently did nothing.
//
// The rule this module enforces: URLs are resolved to plain strings *here*, off
// the render path, and only tracks that actually resolved are handed to the
// markup.
//
// TRACES: UR-020 | DR-023 | UT-143, UT-144
/**
* The subset of `MediaStream` (from the generated bindings) this module needs.
* Kept structural so tests do not have to build full binding objects.
*/
export interface SubtitleStreamLike {
index: number;
kind?: string | null;
language?: string | null;
displayTitle?: string | null;
isDefault?: boolean;
isForced?: boolean;
}
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
export interface RenderableSubtitleTrack {
/** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
streamIndex: number;
/** Fully resolved WebVTT URL. Always a string, never a Promise. */
url: string;
srclang: string;
label: string;
/** Server's "default" flag — shown in the menu, never auto-enabled. */
isDefault: boolean;
}
/** Subtitle streams of a media item, in stream order. */
export function subtitleStreamsOf(
streams: readonly SubtitleStreamLike[] | null | undefined,
): SubtitleStreamLike[] {
if (!streams) return [];
return streams.filter((s) => s.kind === "subtitle");
}
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
return stream.displayTitle || stream.language || `Track ${stream.index}`;
}
/** A src we are willing to put on a `<track>`: a non-blank plain string. */
function isRenderableUrl(url: unknown): url is string {
return typeof url === "string" && url.trim().length > 0;
}
/**
* Resolve every subtitle stream's URL and return only the tracks that can be
* rendered. `resolveUrl` failures are swallowed per track: one unavailable
* subtitle must not cost the user the others, and a dead `src` on a media
* element is exactly what made this block get disabled in the first place.
*/
export async function resolveSubtitleTracks(
streams: readonly SubtitleStreamLike[] | null | undefined,
resolveUrl: (streamIndex: number) => Promise<string>,
): Promise<RenderableSubtitleTrack[]> {
const subtitles = subtitleStreamsOf(streams);
if (subtitles.length === 0) return [];
const resolved = await Promise.all(
subtitles.map(async (stream) => {
try {
const url = await resolveUrl(stream.index);
if (!isRenderableUrl(url)) return null;
return {
streamIndex: stream.index,
url,
srclang: stream.language || "und",
label: subtitleTrackLabel(stream),
isDefault: stream.isDefault === true,
} satisfies RenderableSubtitleTrack;
} catch {
return null;
}
}),
);
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
}
/**
* The selection to keep once the rendered track list changes.
*
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
* markup deliberately omits the `default` attribute, which would auto-show the
* track) the menu opens on "Off", so auto-enabling would make the UI lie about
* what is on screen, and it would change behaviour for every user who has never
* asked for subtitles.
*
* A selection that is no longer renderable (new item, or a URL that failed to
* resolve) collapses to off, so the menu's checkmark can never point at a track
* that does not exist on the element.
*/
export function reconcileSelectedSubtitle(
tracks: readonly RenderableSubtitleTrack[],
selected: number | null,
): number | null {
if (selected === null) return null;
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
}
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin;
} catch {
return null;
}
}
/**
* The `crossorigin` value for the `<video>` element, or undefined for none.
*
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
* element opts in. The webview page's origin is `tauri://localhost`, so every
* subtitle served by Jellyfin is cross-origin.
*
* Opting in is only safe when the media itself comes from an http(s) server
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
* the attribute off: subtitles staying dark there is the status quo, whereas
* forcing CORS onto the video fetch could break playback outright.
*
* Deliberately keyed on the *count of subtitle streams* rather than on the
* resolved tracks: both inputs are known at first render, so the attribute is
* decided before the element starts loading and never flips underneath an
* in-flight media fetch.
*/
export function videoCrossOriginMode(
streamUrl: string,
subtitleStreamCount: number,
): "anonymous" | undefined {
if (subtitleStreamCount <= 0) return undefined;
return originOf(streamUrl) ? "anonymous" : undefined;
}