Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ffabee06 | ||
|
|
13e0860401 | ||
|
|
d1c01a6bc3 | ||
|
|
e5d3cc06f2 |
@@ -71,6 +71,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||||
|
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,6 +246,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||||
|
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -313,6 +315,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087 |
|
| UR-058 | - | DR-087 |
|
||||||
| UR-060 | - | DR-090, DR-091 |
|
| UR-060 | - | DR-090, DR-091 |
|
||||||
|
| UR-061 | - | DR-092 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -383,6 +386,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||||
|
| UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
|
||||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
||||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
||||||
@@ -402,6 +406,10 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||||
|
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
|
||||||
|
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done |
|
||||||
|
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done |
|
||||||
|
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
+291
-223
@@ -1,22 +1,22 @@
|
|||||||
# Code Traceability Matrix
|
# Code Traceability Matrix
|
||||||
|
|
||||||
**Generated:** 7/25/2026, 3:13:47 PM
|
**Generated:** 7/28/2026, 1:33:13 AM
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **Total Files Scanned:** 296
|
- **Total Files Scanned:** 299
|
||||||
- **Total TRACES Found:** 310
|
- **Total TRACES Found:** 313
|
||||||
- **Requirements Covered:**
|
- **Requirements Covered:**
|
||||||
- User Requirements (UR): 57
|
- User Requirements (UR): 58
|
||||||
- Integration Requirements (IR): 15
|
- Integration Requirements (IR): 15
|
||||||
- Development Requirements (DR): 83
|
- Development Requirements (DR): 84
|
||||||
- Jellyfin API Requirements (JA): 24
|
- Jellyfin API Requirements (JA): 24
|
||||||
|
|
||||||
## Requirements by Type
|
## Requirements by Type
|
||||||
|
|
||||||
### User Requirements (UR)
|
### User Requirements (UR)
|
||||||
```
|
```
|
||||||
UR-002, UR-003, UR-004, UR-005, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058, UR-059, UR-060
|
UR-002, UR-003, UR-004, UR-005, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058, UR-059, UR-060, UR-061
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration Requirements (IR)
|
### Integration Requirements (IR)
|
||||||
@@ -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)
|
### 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-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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Jellyfin API Requirements (JA)
|
### Jellyfin API Requirements (JA)
|
||||||
@@ -192,11 +192,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### IR-025
|
### IR-025
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/utils/backgroundAudio.ts`](src/lib/utils/backgroundAudio.ts#L4)
|
- **File:** [`src/lib/utils/backgroundAudio.ts`](src/lib/utils/backgroundAudio.ts#L4)
|
||||||
- **Line:** 4
|
- **Line:** 4
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/utils/backgroundAudio.test.ts`](src/lib/utils/backgroundAudio.test.ts#L7)
|
||||||
|
- **Line:** 7
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### IR-026
|
### IR-026
|
||||||
|
|
||||||
@@ -792,7 +795,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### DR-029
|
### DR-029
|
||||||
|
|
||||||
**Locations:** 9 file(s)
|
**Locations:** 10 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2547)
|
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2547)
|
||||||
- **Line:** 2547
|
- **Line:** 2547
|
||||||
@@ -818,6 +821,9 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2)
|
- **File:** [`src-tauri/src/player/autoplay.rs`](src-tauri/src/player/autoplay.rs#L2)
|
||||||
- **Line:** 2
|
- **Line:** 2
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L257)
|
||||||
|
- **Line:** 257
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3)
|
- **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -1169,7 +1175,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### DR-051
|
### DR-051
|
||||||
|
|
||||||
**Locations:** 2 file(s)
|
**Locations:** 3 file(s)
|
||||||
|
|
||||||
- **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
|
||||||
@@ -1177,6 +1183,9 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **File:** [`src/lib/utils/backgroundAudio.ts`](src/lib/utils/backgroundAudio.ts#L4)
|
- **File:** [`src/lib/utils/backgroundAudio.ts`](src/lib/utils/backgroundAudio.ts#L4)
|
||||||
- **Line:** 4
|
- **Line:** 4
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/utils/backgroundAudio.test.ts`](src/lib/utils/backgroundAudio.test.ts#L7)
|
||||||
|
- **Line:** 7
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### DR-052
|
### DR-052
|
||||||
|
|
||||||
@@ -1200,8 +1209,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7)
|
- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7)
|
||||||
- **Line:** 7
|
- **Line:** 7
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L1079)
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L1105)
|
||||||
- **Line:** 1079
|
- **Line:** 1105
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L68)
|
- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L68)
|
||||||
- **Line:** 68
|
- **Line:** 68
|
||||||
@@ -1816,6 +1825,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 183
|
- **Line:** 183
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### DR-092
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
|
- **Line:** 10
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
|
||||||
|
- **Line:** 1
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### JA-001
|
### JA-001
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -2280,7 +2300,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### UR-005
|
### UR-005
|
||||||
|
|
||||||
**Locations:** 59 file(s)
|
**Locations:** 60 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2251)
|
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2251)
|
||||||
- **Line:** 2251
|
- **Line:** 2251
|
||||||
@@ -2303,6 +2323,9 @@ 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/videoFit.ts`](src/lib/components/player/videoFit.ts#L7)
|
- **File:** [`src/lib/components/player/videoFit.ts`](src/lib/components/player/videoFit.ts#L7)
|
||||||
- **Line:** 7
|
- **Line:** 7
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
|
- **Line:** 10
|
||||||
|
- **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`
|
||||||
@@ -2833,46 +2856,9 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 157
|
- **Line:** 157
|
||||||
- **Context:** `pub fn file_size(&self) -> Option<u64> {`
|
- **Context:** `pub fn file_size(&self) -> Option<u64> {`
|
||||||
|
|
||||||
### 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/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19)
|
|
||||||
- **Line:** 19
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15)
|
|
||||||
- **Line:** 15
|
|
||||||
- **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/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19)
|
|
||||||
- **Line:** 19
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15)
|
|
||||||
- **Line:** 15
|
|
||||||
- **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-023
|
### UR-023
|
||||||
|
|
||||||
**Locations:** 20 file(s)
|
**Locations:** 21 file(s)
|
||||||
|
|
||||||
- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1)
|
- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1)
|
||||||
- **Line:** 1
|
- **Line:** 1
|
||||||
@@ -2925,8 +2911,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/player/mod.rs`](src-tauri/src/player/mod.rs#L2)
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2)
|
||||||
- **Line:** 2
|
- **Line:** 2
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L1079)
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L257)
|
||||||
- **Line:** 1079
|
- **Line:** 257
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L1105)
|
||||||
|
- **Line:** 1105
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3)
|
- **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
@@ -3001,7 +2990,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### UR-026
|
### UR-026
|
||||||
|
|
||||||
**Locations:** 16 file(s)
|
**Locations:** 17 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2308)
|
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2308)
|
||||||
- **Line:** 2308
|
- **Line:** 2308
|
||||||
@@ -3048,6 +3037,9 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2)
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L2)
|
||||||
- **Line:** 2
|
- **Line:** 2
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L257)
|
||||||
|
- **Line:** 257
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3)
|
- **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -3240,7 +3232,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### UR-040
|
### UR-040
|
||||||
|
|
||||||
**Locations:** 21 file(s)
|
**Locations:** 22 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434)
|
- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434)
|
||||||
- **Line:** 434
|
- **Line:** 434
|
||||||
@@ -3272,11 +3264,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **File:** [`src/lib/utils/backgroundAudio.ts`](src/lib/utils/backgroundAudio.ts#L4)
|
- **File:** [`src/lib/utils/backgroundAudio.ts`](src/lib/utils/backgroundAudio.ts#L4)
|
||||||
- **Line:** 4
|
- **Line:** 4
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/utils/backgroundAudio.test.ts`](src/lib/utils/backgroundAudio.test.ts#L7)
|
||||||
|
- **Line:** 7
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7)
|
- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7)
|
||||||
- **Line:** 7
|
- **Line:** 7
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L1079)
|
- **File:** [`src-tauri/src/player/mod.rs`](src-tauri/src/player/mod.rs#L1105)
|
||||||
- **Line:** 1079
|
- **Line:** 1105
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L68)
|
- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L68)
|
||||||
- **Line:** 68
|
- **Line:** 68
|
||||||
@@ -3692,22 +3687,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 37
|
- **Line:** 37
|
||||||
- **Context:** `Unknown`
|
- **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)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8)
|
|
||||||
- **Line:** 8
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### IT-013
|
### IT-013
|
||||||
|
|
||||||
**Locations:** 4 file(s)
|
**Locations:** 4 file(s)
|
||||||
@@ -3725,30 +3704,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 675
|
- **Line:** 675
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-003
|
|
||||||
|
|
||||||
**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-004
|
|
||||||
|
|
||||||
**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-005
|
|
||||||
|
|
||||||
**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-010
|
### UT-010
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -3757,14 +3712,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`
|
||||||
|
|
||||||
### 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) {`
|
|
||||||
|
|
||||||
### UT-024
|
### UT-024
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -3784,26 +3731,10 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 251
|
- **Line:** 251
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-027
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
|
||||||
- **Line:** 245
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UT-028
|
### UT-028
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
|
||||||
- **Line:** 245
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UT-029
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
||||||
- **Line:** 245
|
- **Line:** 245
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -3816,15 +3747,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 245
|
- **Line:** 245
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-031
|
### 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-033
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
@@ -3906,12 +3829,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 9
|
- **Line:** 9
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-066
|
### UR-061
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/services/networkType.test.ts`](src/lib/services/networkType.test.ts#L4)
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
- **Line:** 4
|
- **Line:** 10
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
|
||||||
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-071
|
### UT-071
|
||||||
@@ -3922,6 +3848,47 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 4
|
- **Line:** 4
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-085
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
|
- **Line:** 10
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704)
|
||||||
|
- **Line:** 704
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-086
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
|
- **Line:** 10
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714)
|
||||||
|
- **Line:** 714
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-087
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
|
- **Line:** 10
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759)
|
||||||
|
- **Line:** 759
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-088
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L10)
|
||||||
|
- **Line:** 10
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-014
|
### UR-014
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -3930,15 +3897,41 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 4
|
- **Line:** 4
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-016
|
### UR-020
|
||||||
|
|
||||||
**Locations:** 2 file(s)
|
**Locations:** 4 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L62)
|
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
|
||||||
- **Line:** 62
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L251)
|
- **File:** [`src/lib/player/adapters/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19)
|
||||||
- **Line:** 251
|
- **Line:** 19
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15)
|
||||||
|
- **Line:** 15
|
||||||
|
- **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/html5Adapter.ts`](src/lib/player/adapters/html5Adapter.ts#L19)
|
||||||
|
- **Line:** 19
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/player/adapters/types.ts`](src/lib/player/adapters/types.ts#L15)
|
||||||
|
- **Line:** 15
|
||||||
|
- **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`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-060
|
### UR-060
|
||||||
@@ -3955,6 +3948,22 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 106
|
- **Line:** 106
|
||||||
- **Context:** `Unknown`
|
- **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)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8)
|
||||||
|
- **Line:** 8
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### IT-016
|
### IT-016
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -3971,6 +3980,38 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 441
|
- **Line:** 441
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-003
|
||||||
|
|
||||||
|
**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-004
|
||||||
|
|
||||||
|
**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-005
|
||||||
|
|
||||||
|
**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) {`
|
||||||
|
|
||||||
### UT-014
|
### UT-014
|
||||||
|
|
||||||
**Locations:** 2 file(s)
|
**Locations:** 2 file(s)
|
||||||
@@ -4092,7 +4133,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 157
|
- **Line:** 157
|
||||||
- **Context:** `pub fn file_size(&self) -> Option<u64> {`
|
- **Context:** `pub fn file_size(&self) -> Option<u64> {`
|
||||||
|
|
||||||
### UT-032
|
### UT-027
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
@@ -4100,6 +4141,109 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 245
|
- **Line:** 245
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-029
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
||||||
|
- **Line:** 245
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-031
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
||||||
|
- **Line:** 245
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-033
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
||||||
|
- **Line:** 245
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-042
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027)
|
||||||
|
- **Line:** 2027
|
||||||
|
- **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-062
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/utils/backgroundAudio.test.ts`](src/lib/utils/backgroundAudio.test.ts#L7)
|
||||||
|
- **Line:** 7
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UT-066
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/services/networkType.test.ts`](src/lib/services/networkType.test.ts#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-031
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3)
|
||||||
|
- **Line:** 3
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1)
|
||||||
|
- **Line:** 1
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UR-032
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3)
|
||||||
|
- **Line:** 3
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1)
|
||||||
|
- **Line:** 1
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-033
|
### UR-033
|
||||||
|
|
||||||
**Locations:** 7 file(s)
|
**Locations:** 7 file(s)
|
||||||
@@ -4134,26 +4278,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 2027
|
- **Line:** 2027
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-051
|
### UT-044
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2900)
|
- **File:** [`src-tauri/src/commands/offline.rs`](src-tauri/src/commands/offline.rs#L136)
|
||||||
- **Line:** 2900
|
- **Line:** 136
|
||||||
- **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`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-067
|
### UT-067
|
||||||
@@ -4268,30 +4398,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 685
|
- **Line:** 685
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UT-085
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704)
|
|
||||||
- **Line:** 704
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UT-086
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714)
|
|
||||||
- **Line:** 714
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UT-087
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759)
|
|
||||||
- **Line:** 759
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UR-022
|
### UR-022
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -4300,27 +4406,13 @@ 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-031
|
### UR-042
|
||||||
|
|
||||||
**Locations:** 2 file(s)
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3)
|
- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1)
|
|
||||||
- **Line:** 1
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UR-032
|
|
||||||
|
|
||||||
**Locations:** 2 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3)
|
|
||||||
- **Line:** 3
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1)
|
|
||||||
- **Line:** 1
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UR-044
|
### UR-044
|
||||||
|
|
||||||
@@ -4346,14 +4438,6 @@ 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`
|
||||||
|
|
||||||
### UT-042
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027)
|
|
||||||
- **Line:** 2027
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UR-047
|
### UR-047
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -4362,20 +4446,4 @@ 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`
|
||||||
|
|
||||||
### 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`
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -346,10 +346,12 @@ flowchart TB
|
|||||||
**User Interaction:**
|
**User Interaction:**
|
||||||
- **Tap screen:** Controls reappear for 3 seconds
|
- **Tap screen:** Controls reappear for 3 seconds
|
||||||
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||||
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
- **Double tap right side:** Forward 30 seconds (shows animated feedback with "+30" indicator)
|
||||||
|
- **Single tap play/pause is deferred** by the 300 ms double-tap window, so a double tap
|
||||||
|
skips without also toggling pause (UR-061)
|
||||||
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||||
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||||
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
- **Keyboard arrows:** ← rewind 10s, → forward 30s (desktop/external keyboard)
|
||||||
- **Keyboard space/K:** Toggle play/pause
|
- **Keyboard space/K:** Toggle play/pause
|
||||||
- **Keyboard F:** Toggle fullscreen
|
- **Keyboard F:** Toggle fullscreen
|
||||||
- **Pinch:** Zoom (planned)
|
- **Pinch:** Zoom (planned)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package com.dtourolle.jellytau
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
|
|||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var configAttempts = 0
|
private var configAttempts = 0
|
||||||
private val maxConfigAttempts = 10
|
private val maxConfigAttempts = 10
|
||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
|
||||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||||
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
|
|||||||
*/
|
*/
|
||||||
private var mediaWebView: WebView? = null
|
private var mediaWebView: WebView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WebView the @JavascriptInterface bridges have been injected into.
|
||||||
|
*
|
||||||
|
* addJavascriptInterface must run once per WebView instance: re-injecting
|
||||||
|
* over an already-loaded page hands JS a stale proxy whose methods are gone.
|
||||||
|
* Compared by identity so a genuinely new WebView still gets its bridges.
|
||||||
|
*/
|
||||||
|
private var bridgesInstalledOn: WebView? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
|
|||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
|
|
||||||
// Add JavaScript interface for audio focus control
|
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||||
webView.addJavascriptInterface(object : Any() {
|
//
|
||||||
@JavascriptInterface
|
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
||||||
fun requestAudioFocus() {
|
// every onResume (plus each WebView re-find), so this used to re-inject
|
||||||
handler.post { this@MainActivity.requestAudioFocus() }
|
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
||||||
|
// injected objects at page-load time; re-injecting over a live page
|
||||||
|
// leaves JS holding a stale proxy. The object stays truthy while its
|
||||||
|
// methods vanish, which surfaced as a flood of
|
||||||
|
// "WebView: Unknown object" chromium errors and, in JS,
|
||||||
|
// "TypeError: setEnabled is not a function".
|
||||||
|
//
|
||||||
|
// The visible bug: the background-audio toggle turned blue but never
|
||||||
|
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
||||||
|
// dispatched 'jellytau-background', and a locked screen killed audio
|
||||||
|
// instantly (UR-040). Audio focus and PiP broke the same way.
|
||||||
|
//
|
||||||
|
// The settings/WebChromeClient work below is idempotent and must keep
|
||||||
|
// running on resume; only the bridge injection is one-shot.
|
||||||
|
if (webView === bridgesInstalledOn) {
|
||||||
|
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
bridgesInstalledOn = webView
|
||||||
|
|
||||||
@JavascriptInterface
|
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
|
||||||
fun abandonAudioFocus() {
|
// requests from the WebView competed with Chromium's own
|
||||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
// AudioFocusDelegate and with ExoPlayer, and the resulting
|
||||||
}
|
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
|
||||||
}, "AndroidAudioFocus")
|
// in configureWebViewSettings().
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
|
||||||
|
|
||||||
// Add JavaScript interface for picture-in-picture control.
|
// Add JavaScript interface for picture-in-picture control.
|
||||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||||
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
|
|||||||
backgroundAudioEnabled = enabled
|
backgroundAudioEnabled = enabled
|
||||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
|
||||||
@JavascriptInterface
|
|
||||||
fun isSupported(): Boolean = true
|
|
||||||
}, "AndroidBackgroundAudio")
|
}, "AndroidBackgroundAudio")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||||
|
|
||||||
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
|
|||||||
dispatchWebEvent("jellytau-network-changed")
|
dispatchWebEvent("jellytau-network-changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebView settings, chrome client and the video-unmute script.
|
||||||
|
*
|
||||||
|
* Split out from the bridge injection because this half is idempotent and
|
||||||
|
* must re-run on every resume, whereas addJavascriptInterface must not.
|
||||||
|
*/
|
||||||
|
private fun configureWebViewSettings(webView: WebView) {
|
||||||
|
try {
|
||||||
// Set WebChromeClient to handle video playback and audio focus
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
|
|||||||
super.onHideCustomView()
|
super.onHideCustomView()
|
||||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
|
||||||
|
*
|
||||||
|
* Without this the frontend is invisible to `adb logcat`, which makes
|
||||||
|
* diagnosing anything that spans the JS/native boundary (the
|
||||||
|
* background-audio handoff in particular) guesswork.
|
||||||
|
*/
|
||||||
|
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
|
||||||
|
android.util.Log.d(
|
||||||
|
"JellyTauWeb",
|
||||||
|
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||||
|
|
||||||
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
|
|||||||
video.volume = 1.0;
|
video.volume = 1.0;
|
||||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||||
|
|
||||||
// Add event listeners to manage audio focus
|
// NOTE: deliberately no audio-focus calls here.
|
||||||
video.addEventListener('play', function() {
|
//
|
||||||
console.log('[Android] Video play event - requesting audio focus');
|
// WebView already manages audio focus for <video> through
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
|
||||||
AndroidAudioFocus.requestAudioFocus();
|
// again from MainActivity made two requesters compete inside one
|
||||||
}
|
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
|
||||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
// (~45ms), whose handler paused playback - so arming background
|
||||||
});
|
// audio, or simply pressing play, paused the video in a loop.
|
||||||
|
//
|
||||||
video.addEventListener('pause', function() {
|
// ExoPlayer is the third potential owner and stays authoritative
|
||||||
console.log('[Android] Video pause event - abandoning audio focus');
|
// for native playback (JellyTauPlayer manages its own focus).
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
// Leave focus to whichever engine is actually rendering.
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('ended', function() {
|
|
||||||
console.log('[Android] Video ended event - abandoning audio focus');
|
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('volumechange', function() {
|
video.addEventListener('volumechange', function() {
|
||||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||||
});
|
});
|
||||||
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
|
||||||
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
||||||
.setAudioAttributes(audioAttributes)
|
|
||||||
.setAcceptsDelayedFocusGain(true)
|
|
||||||
.setOnAudioFocusChangeListener { focusChange ->
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val result = audioManager.requestAudioFocus(
|
|
||||||
{ focusChange ->
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
|
||||||
},
|
|
||||||
AudioManager.STREAM_MUSIC,
|
|
||||||
AudioManager.AUDIOFOCUS_GAIN
|
|
||||||
)
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun abandonAudioFocus() {
|
|
||||||
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
audioFocusRequest?.let {
|
|
||||||
audioManager.abandonAudioFocusRequest(it)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
audioManager.abandonAudioFocus { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,23 @@ impl PlayerController {
|
|||||||
self.end_reason.lock_safe().take()
|
self.end_reason.lock_safe().take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record that playback is being stopped by an expiring sleep timer.
|
||||||
|
///
|
||||||
|
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
|
||||||
|
/// Android), which lands in `on_playback_ended`. Without an end reason that
|
||||||
|
/// reads as a natural finish and autoplay advances — defeating the timer.
|
||||||
|
/// `UserStop` is the honest label: the stop was user-initiated, just via the
|
||||||
|
/// timer they set rather than the stop button.
|
||||||
|
///
|
||||||
|
/// Takes the shared slot rather than `&self` so the sleep-timer thread —
|
||||||
|
/// which owns clones, not the controller — records it the same way.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-026 | DR-029
|
||||||
|
fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
|
||||||
|
log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
|
||||||
|
*end_reason.lock_safe() = Some(EndReason::UserStop);
|
||||||
|
}
|
||||||
|
|
||||||
/// Increment autoplay episode counter. Returns true if limit is reached.
|
/// Increment autoplay episode counter. Returns true if limit is reached.
|
||||||
fn increment_autoplay_count(&self) -> bool {
|
fn increment_autoplay_count(&self) -> bool {
|
||||||
let max = self.autoplay_settings.lock_safe().max_episodes;
|
let max = self.autoplay_settings.lock_safe().max_episodes;
|
||||||
@@ -767,6 +784,7 @@ impl PlayerController {
|
|||||||
let sleep_timer = self.sleep_timer.clone();
|
let sleep_timer = self.sleep_timer.clone();
|
||||||
let event_emitter = self.event_emitter.clone();
|
let event_emitter = self.event_emitter.clone();
|
||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
|
let end_reason = self.end_reason.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
@@ -783,6 +801,14 @@ impl PlayerController {
|
|||||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
|
|
||||||
|
// Mark the stop *before* it reaches the backend. Stopping
|
||||||
|
// makes the native player fire its ended callback, and
|
||||||
|
// cancelling the timer above means on_playback_ended can no
|
||||||
|
// longer tell this apart from a natural end — without this
|
||||||
|
// it would show the next-episode popup / autoplay right
|
||||||
|
// after the sleep timer fired.
|
||||||
|
Self::note_sleep_timer_stop(&end_reason);
|
||||||
|
|
||||||
// Emit cancelled state
|
// Emit cancelled state
|
||||||
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
@@ -2040,6 +2066,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A time-based sleep timer that fires mid-episode must not let the ended
|
||||||
|
/// callback fall through to autoplay.
|
||||||
|
///
|
||||||
|
/// The timer thread stops the backend directly, which makes ExoPlayer emit
|
||||||
|
/// its ended callback. That callback races the thread's own `timer.cancel()`:
|
||||||
|
/// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
|
||||||
|
/// so the timer branch is skipped and the episode path runs — showing a
|
||||||
|
/// next-episode popup (or advancing) after the user's sleep timer expired.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_expired_time_sleep_timer_stops_without_autoplay() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
let items = create_test_items(3);
|
||||||
|
controller.play_queue(items, 0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
// Arm a time-based timer that is already due, then let the real timer
|
||||||
|
// thread (started in the constructor, 1s tick) observe the expiry and
|
||||||
|
// run its stop path. Driving the actual thread is the point: the bug was
|
||||||
|
// that this path stopped the backend without recording an end reason.
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
|
||||||
|
|
||||||
|
// Wait for the timer thread to process the expiry (tick is 1s).
|
||||||
|
for _ in 0..40 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
if !controller.sleep_timer.lock_safe().is_active() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!controller.sleep_timer.lock_safe().is_active(),
|
||||||
|
"Timer thread should have expired and cancelled the sleep timer"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The backend stop above makes the native player fire its ended callback.
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::Stop),
|
||||||
|
"Expected Stop after an expired time-based sleep timer, got {:?}",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_empty_queue_stops() {
|
async fn test_empty_queue_stops() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
<!-- 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 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, untrack } from "svelte";
|
import { onMount, onDestroy, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -20,6 +20,14 @@
|
|||||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||||
|
import {
|
||||||
|
createTapGestureState,
|
||||||
|
registerTap,
|
||||||
|
resolveSeekTarget,
|
||||||
|
SEEK_FORWARD_SECONDS,
|
||||||
|
SEEK_BACKWARD_SECONDS,
|
||||||
|
type TapFeedback,
|
||||||
|
} from "./tapGestures";
|
||||||
import {
|
import {
|
||||||
setBackgroundAudioEnabled,
|
setBackgroundAudioEnabled,
|
||||||
subscribeAppBackgrounded,
|
subscribeAppBackgrounded,
|
||||||
@@ -102,11 +110,14 @@
|
|||||||
let touchStartX = $state(0);
|
let touchStartX = $state(0);
|
||||||
let touchStartY = $state(0);
|
let touchStartY = $state(0);
|
||||||
let touchStartTime = $state(0);
|
let touchStartTime = $state(0);
|
||||||
let lastTapTime = $state(0);
|
let tapGestures = createTapGestureState();
|
||||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let brightness = $state(1); // 0-2, default 1
|
let brightness = $state(1); // 0-2, default 1
|
||||||
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
|
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Target of a skip already requested but not yet reported back by the player,
|
||||||
|
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||||
|
let pendingSeekTarget: number | null = null;
|
||||||
let swipeGestureActive = $state(false);
|
let swipeGestureActive = $state(false);
|
||||||
|
|
||||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||||
@@ -703,6 +714,16 @@
|
|||||||
if (debugLogInterval) {
|
if (debugLogInterval) {
|
||||||
clearInterval(debugLogInterval);
|
clearInterval(debugLogInterval);
|
||||||
}
|
}
|
||||||
|
// A deferred single tap must not fire play/pause after teardown.
|
||||||
|
if (tapTimeout) {
|
||||||
|
clearTimeout(tapTimeout);
|
||||||
|
tapTimeout = null;
|
||||||
|
}
|
||||||
|
tapGestures.cancel();
|
||||||
|
if (doubleTapFeedbackTimeout) {
|
||||||
|
clearTimeout(doubleTapFeedbackTimeout);
|
||||||
|
doubleTapFeedbackTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||||
for (const unlisten of nativeUnlisteners) {
|
for (const unlisten of nativeUnlisteners) {
|
||||||
@@ -1192,9 +1213,13 @@
|
|||||||
|
|
||||||
function toggleBackgroundAudio() {
|
function toggleBackgroundAudio() {
|
||||||
backgroundAudioOn = !backgroundAudioOn;
|
backgroundAudioOn = !backgroundAudioOn;
|
||||||
|
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
||||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||||
// so exactly one background behavior is active.
|
// so exactly one background behavior is active.
|
||||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||||
|
if (!armed) {
|
||||||
|
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
||||||
|
}
|
||||||
setAutoEnterEnabled(!backgroundAudioOn);
|
setAutoEnterEnabled(!backgroundAudioOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,7 +1367,16 @@
|
|||||||
async function seekRelative(seconds: number) {
|
async function seekRelative(seconds: number) {
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
|
|
||||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
// The facade seeks by absolute position, so resolve the delta here —
|
||||||
|
// chaining off a still-in-flight target so rapid double taps accumulate
|
||||||
|
// instead of all resolving against the same not-yet-updated position.
|
||||||
|
const newTime = resolveSeekTarget({
|
||||||
|
delta: seconds,
|
||||||
|
reportedPosition: currentTime,
|
||||||
|
duration,
|
||||||
|
pendingTarget: pendingSeekTarget,
|
||||||
|
});
|
||||||
|
pendingSeekTarget = newTime;
|
||||||
|
|
||||||
console.log("[VideoPlayer] Relative seek:", {
|
console.log("[VideoPlayer] Relative seek:", {
|
||||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||||
@@ -1358,7 +1392,12 @@
|
|||||||
}
|
}
|
||||||
} as unknown as Event;
|
} as unknown as Event;
|
||||||
|
|
||||||
|
try {
|
||||||
await handleSeekBarChange(syntheticEvent);
|
await handleSeekBarChange(syntheticEvent);
|
||||||
|
} finally {
|
||||||
|
// The player is authoritative again from here on.
|
||||||
|
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -1375,10 +1414,10 @@
|
|||||||
}
|
}
|
||||||
} else if (e.key === "ArrowLeft") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
seekRelative(-10);
|
seekRelative(SEEK_BACKWARD_SECONDS);
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
seekRelative(10);
|
seekRelative(SEEK_FORWARD_SECONDS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1389,25 +1428,31 @@
|
|||||||
touchStartY = touch.clientY;
|
touchStartY = touch.clientY;
|
||||||
touchStartTime = Date.now();
|
touchStartTime = Date.now();
|
||||||
|
|
||||||
const now = Date.now();
|
const outcome = registerTap(tapGestures, {
|
||||||
const timeSinceLastTap = now - lastTapTime;
|
x: touch.clientX,
|
||||||
|
screenWidth: window.innerWidth,
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
// Double tap detection (within 300ms)
|
|
||||||
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleDoubleTap(touch.clientX);
|
|
||||||
lastTapTime = 0; // Reset to prevent triple-tap
|
|
||||||
if (tapTimeout) {
|
if (tapTimeout) {
|
||||||
clearTimeout(tapTimeout);
|
clearTimeout(tapTimeout);
|
||||||
tapTimeout = null;
|
tapTimeout = null;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
lastTapTime = now;
|
if (outcome.action === "seek") {
|
||||||
// Set timeout to clear if no second tap
|
e.preventDefault();
|
||||||
tapTimeout = setTimeout(() => {
|
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||||
lastTapTime = 0;
|
return;
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single tap so far: defer play/pause until the double-tap window closes,
|
||||||
|
// so a double tap seeks without also toggling pause.
|
||||||
|
tapTimeout = setTimeout(() => {
|
||||||
|
tapTimeout = null;
|
||||||
|
if (tapGestures.resolvePending(Date.now())) {
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
}, outcome.pendingAfterMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
@@ -1422,6 +1467,13 @@
|
|||||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||||
swipeGestureActive = true;
|
swipeGestureActive = true;
|
||||||
|
|
||||||
|
// This is a swipe, not a tap — drop the deferred play/pause.
|
||||||
|
tapGestures.cancel();
|
||||||
|
if (tapTimeout) {
|
||||||
|
clearTimeout(tapTimeout);
|
||||||
|
tapTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Brightness control on vertical swipe
|
// Brightness control on vertical swipe
|
||||||
swipeType = "brightness";
|
swipeType = "brightness";
|
||||||
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
||||||
@@ -1438,20 +1490,22 @@
|
|||||||
swipeType = null;
|
swipeType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDoubleTap(x: number) {
|
/**
|
||||||
const screenWidth = window.innerWidth;
|
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
||||||
const isLeftSide = x < screenWidth / 2;
|
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
||||||
|
* so the compatibility click that follows a tap must be ignored here —
|
||||||
if (isLeftSide) {
|
* otherwise it pauses on the first tap of a double tap.
|
||||||
// Double tap left: rewind 10 seconds
|
*/
|
||||||
seekRelative(-10);
|
function handleVideoClick(e: MouseEvent) {
|
||||||
showDoubleTapFeedback = "left";
|
// A click synthesized from a touch reports no pointer movement detail.
|
||||||
} else {
|
if (e.detail === 0 || tapTimeout !== null) return;
|
||||||
// Double tap right: forward 10 seconds
|
togglePlayPause();
|
||||||
seekRelative(10);
|
|
||||||
showDoubleTapFeedback = "right";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
|
||||||
|
seekRelative(seekSeconds);
|
||||||
|
showDoubleTapFeedback = feedback;
|
||||||
|
|
||||||
// Hide feedback after animation
|
// Hide feedback after animation
|
||||||
if (doubleTapFeedbackTimeout) {
|
if (doubleTapFeedbackTimeout) {
|
||||||
clearTimeout(doubleTapFeedbackTimeout);
|
clearTimeout(doubleTapFeedbackTimeout);
|
||||||
@@ -1612,7 +1666,7 @@
|
|||||||
onwaiting={handleWaiting}
|
onwaiting={handleWaiting}
|
||||||
onplaying={handlePlaying}
|
onplaying={handlePlaying}
|
||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={togglePlayPause}
|
onclick={handleVideoClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!-- Temporarily disabled to debug playback issues
|
||||||
{#each subtitleTracks() as track}
|
{#each subtitleTracks() as track}
|
||||||
@@ -1665,7 +1719,7 @@
|
|||||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
|
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1676,7 +1730,7 @@
|
|||||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
|
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
DOUBLE_TAP_WINDOW_MS,
|
||||||
|
SEEK_FORWARD_SECONDS,
|
||||||
|
SEEK_BACKWARD_SECONDS,
|
||||||
|
createTapGestureState,
|
||||||
|
registerTap,
|
||||||
|
resolveSeekTarget,
|
||||||
|
} from "./tapGestures";
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = 1000;
|
||||||
|
const LEFT = 100;
|
||||||
|
const RIGHT = 900;
|
||||||
|
|
||||||
|
function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
|
||||||
|
return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
|
||||||
|
function asSeek(outcome: ReturnType<typeof tap>) {
|
||||||
|
if (outcome.action !== "seek") {
|
||||||
|
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tap gesture resolution", () => {
|
||||||
|
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
const first = tap(state, RIGHT, 1000);
|
||||||
|
|
||||||
|
// The first tap must NOT immediately toggle play/pause — it may still
|
||||||
|
// become a double tap.
|
||||||
|
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
|
||||||
|
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
||||||
|
expect(resolved).toEqual({ action: "togglePlayPause" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
const second = asSeek(tap(state, RIGHT, 1150));
|
||||||
|
|
||||||
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||||
|
expect(second.seekSeconds).toBe(30);
|
||||||
|
expect(second.feedback).toBe("right");
|
||||||
|
|
||||||
|
// The deferred single-tap pause must have been cancelled.
|
||||||
|
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeks back 10s on a double tap on the left half", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, LEFT, 1000);
|
||||||
|
const second = asSeek(tap(state, LEFT, 1100));
|
||||||
|
|
||||||
|
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||||
|
expect(second.seekSeconds).toBe(-10);
|
||||||
|
expect(second.feedback).toBe("left");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a second tap after the window as a new pending single tap", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||||
|
|
||||||
|
expect(late.action).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a third tap as another double tap", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||||
|
|
||||||
|
// Triple tap: the third tap starts a fresh pending tap rather than
|
||||||
|
// seeking again off the consumed second tap.
|
||||||
|
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates repeated double taps on the same side", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
const a = asSeek(tap(state, RIGHT, 1100));
|
||||||
|
tap(state, RIGHT, 1200);
|
||||||
|
const b = asSeek(tap(state, RIGHT, 1300));
|
||||||
|
|
||||||
|
expect(a.seekSeconds).toBe(30);
|
||||||
|
expect(b.seekSeconds).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the tap side, so a double tap split across halves follows the second tap", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, LEFT, 1000);
|
||||||
|
const second = asSeek(tap(state, RIGHT, 1100));
|
||||||
|
|
||||||
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||||
|
expect(second.feedback).toBe("right");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||||
|
const state = createTapGestureState();
|
||||||
|
tap(state, RIGHT, 1000);
|
||||||
|
state.cancel();
|
||||||
|
|
||||||
|
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seek target resolution", () => {
|
||||||
|
const DURATION = 600;
|
||||||
|
|
||||||
|
it("adds the delta to the reported position", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to zero when rewinding past the start", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to the duration when skipping past the end", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||||
|
// The player has not yet reported the first seek's result, so the
|
||||||
|
// reported position is still the pre-seek value.
|
||||||
|
const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
|
||||||
|
const second = resolveSeekTarget({
|
||||||
|
delta: 30,
|
||||||
|
reportedPosition: 100,
|
||||||
|
duration: DURATION,
|
||||||
|
pendingTarget: first,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a pending target once the player has caught up past it", () => {
|
||||||
|
const target = resolveSeekTarget({
|
||||||
|
delta: 30,
|
||||||
|
reportedPosition: 200,
|
||||||
|
duration: DURATION,
|
||||||
|
pendingTarget: 130,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target).toBe(230);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the delta alone when duration is unknown", () => {
|
||||||
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Tap-gesture interpretation for the video player surface.
|
||||||
|
*
|
||||||
|
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
||||||
|
* a tap cannot be classified at the moment it lands, because it may still turn
|
||||||
|
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
||||||
|
* until the double-tap window closes, and cancelled outright if a second tap
|
||||||
|
* arrives — otherwise a double tap both toggles pause and seeks.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A second tap within this window makes a double tap. */
|
||||||
|
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||||
|
|
||||||
|
/** Double tap on the right half: skip forward. */
|
||||||
|
export const SEEK_FORWARD_SECONDS = 30;
|
||||||
|
|
||||||
|
/** Double tap on the left half: skip back. */
|
||||||
|
export const SEEK_BACKWARD_SECONDS = -10;
|
||||||
|
|
||||||
|
export type TapFeedback = "left" | "right";
|
||||||
|
|
||||||
|
export type TapOutcome =
|
||||||
|
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
||||||
|
| { action: "pending"; pendingAfterMs: number }
|
||||||
|
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
||||||
|
|
||||||
|
export interface TapInput {
|
||||||
|
/** Tap x position, viewport pixels. */
|
||||||
|
x: number;
|
||||||
|
screenWidth: number;
|
||||||
|
now: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TapGestureState {
|
||||||
|
/**
|
||||||
|
* Resolve a still-pending single tap. Returns the play/pause action once the
|
||||||
|
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
||||||
|
* became a double tap, or was cancelled).
|
||||||
|
*/
|
||||||
|
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
||||||
|
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalState extends TapGestureState {
|
||||||
|
lastTapTime: number;
|
||||||
|
pendingSince: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTapGestureState(): TapGestureState {
|
||||||
|
const state: InternalState = {
|
||||||
|
lastTapTime: 0,
|
||||||
|
pendingSince: null,
|
||||||
|
resolvePending(now: number) {
|
||||||
|
if (state.pendingSince === null) return null;
|
||||||
|
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
||||||
|
state.pendingSince = null;
|
||||||
|
return { action: "togglePlayPause" };
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.pendingSince = null;
|
||||||
|
state.lastTapTime = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
||||||
|
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
||||||
|
* the window returns the seek and clears the pending play/pause.
|
||||||
|
*/
|
||||||
|
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||||
|
const s = state as InternalState;
|
||||||
|
const sinceLastTap = input.now - s.lastTapTime;
|
||||||
|
|
||||||
|
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||||
|
// Second tap: cancel the deferred play/pause and seek instead.
|
||||||
|
s.pendingSince = null;
|
||||||
|
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
||||||
|
const isLeftSide = input.x < input.screenWidth / 2;
|
||||||
|
return isLeftSide
|
||||||
|
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
||||||
|
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastTapTime = input.now;
|
||||||
|
s.pendingSince = input.now;
|
||||||
|
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekTargetInput {
|
||||||
|
/** Relative offset in seconds (negative rewinds). */
|
||||||
|
delta: number;
|
||||||
|
/** Latest position reported by the player — the authoritative source. */
|
||||||
|
reportedPosition: number;
|
||||||
|
/** Media duration; 0/unknown disables the upper clamp. */
|
||||||
|
duration: number;
|
||||||
|
/**
|
||||||
|
* Target of a seek already requested but not yet reflected in
|
||||||
|
* `reportedPosition`. Consecutive double taps chain off this so they add up
|
||||||
|
* instead of all resolving against the same stale position.
|
||||||
|
*/
|
||||||
|
pendingTarget?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a relative skip to the absolute position the facade expects.
|
||||||
|
*
|
||||||
|
* The player facade seeks by absolute position only (the backend picks the seek
|
||||||
|
* strategy), so the delta is applied here — against the pending target when one
|
||||||
|
* is still in flight and still ahead of what the player has reported.
|
||||||
|
*/
|
||||||
|
export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||||
|
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||||
|
|
||||||
|
const base =
|
||||||
|
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||||
|
? pendingTarget
|
||||||
|
: reportedPosition;
|
||||||
|
|
||||||
|
const target = base + delta;
|
||||||
|
if (target < 0) return 0;
|
||||||
|
if (duration > 0 && target > duration) return duration;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { setBackgroundAudioEnabled } from "./backgroundAudio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge-reporting contract for the background-audio toggle.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040 | IR-025, DR-051 | UT-062
|
||||||
|
*
|
||||||
|
* Regression guard for the "screen lock kills video audio" bug: MainActivity
|
||||||
|
* re-ran configureWebViewForMedia() on every onResume, re-calling
|
||||||
|
* addJavascriptInterface over a live page. WebView then served a stale proxy —
|
||||||
|
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
|
||||||
|
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
|
||||||
|
*
|
||||||
|
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
|
||||||
|
* a try/catch returning void, so the UI showed "armed" while native never got
|
||||||
|
* the flag — and onStop's `if (backgroundAudioEnabled)` guard never dispatched
|
||||||
|
* `jellytau-background`. Audio died the instant the screen locked.
|
||||||
|
*
|
||||||
|
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
|
||||||
|
* reached, so a dead bridge can never masquerade as an armed toggle.
|
||||||
|
*/
|
||||||
|
describe("setBackgroundAudioEnabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports success when the bridge is present and the call lands", () => {
|
||||||
|
const setEnabled = vi.fn();
|
||||||
|
window.AndroidBackgroundAudio = { setEnabled };
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(true);
|
||||||
|
expect(setEnabled).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the bridge object is absent entirely", () => {
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure for a stale proxy whose methods are gone", () => {
|
||||||
|
// The exact shape of the bug: object present (so `?.` passes) but the
|
||||||
|
// method is missing after re-injection over a live page.
|
||||||
|
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the bridge method throws", () => {
|
||||||
|
window.AndroidBackgroundAudio = {
|
||||||
|
setEnabled: () => {
|
||||||
|
throw new TypeError("e.setEnabled is not a function");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws out to the caller — the toggle must not break the player", () => {
|
||||||
|
window.AndroidBackgroundAudio = {
|
||||||
|
setEnabled: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
interface AndroidBackgroundAudioBridge {
|
interface AndroidBackgroundAudioBridge {
|
||||||
setEnabled(enabled: boolean): void;
|
setEnabled(enabled: boolean): void;
|
||||||
isSupported(): boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
|
|||||||
return window.AndroidBackgroundAudio;
|
return window.AndroidBackgroundAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
|
||||||
export function isBackgroundAudioSupported(): boolean {
|
|
||||||
try {
|
|
||||||
return bridge()?.isSupported() ?? false;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[BgAudio] isSupported check failed:", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||||
* side runs the audio handoff on background instead of entering PiP.
|
* side runs the audio handoff on background instead of entering PiP.
|
||||||
*/
|
*/
|
||||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
||||||
|
const b = bridge();
|
||||||
|
if (!b) {
|
||||||
|
// The button is gated on platform(), not on this bridge, so it can render
|
||||||
|
// before/without the bridge existing. Silently no-oping here leaves the UI
|
||||||
|
// showing "armed" while native never learns — and the handoff then never
|
||||||
|
// fires on lock. Report it so callers can retry.
|
||||||
|
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
bridge()?.setEnabled(enabled);
|
b.setEnabled(enabled);
|
||||||
|
console.log("[BgAudio] setEnabled ->", enabled);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user