Compare commits

..
4 Commits
Author SHA1 Message Date
dtourolle 37ffabee06 chore(release): bump to 0.1.5; regenerate traceability matrix
Build & Release / Run Tests (push) Successful in 4m35s
Build & Release / Build Linux (push) Successful in 18m25s
Build & Release / Build Windows (push) Successful in 13m27s
Build & Release / Build Android (push) Successful in 29m9s
Build & Release / Create Release (push) Successful in 16s
Registers UR-061/DR-092 (tap gestures) and UT-062 (background-audio
bridge reporting), and regenerates the matrix — 313 TRACES across 299
files.
2026-07-28 01:33:20 +02:00
dtourolle 13e0860401 fix(player): expired sleep timer stops without triggering autoplay
Stopping the backend makes the native player fire its ended callback,
which lands in on_playback_ended. The timer thread cancels the timer
first, so by the time the callback inspects it the mode reads Off — the
sleep-timer branch is skipped and the episode path runs, showing a
next-episode popup (or advancing outright) right after the user's sleep
timer expired.

Record EndReason::UserStop before the stop reaches the backend. That is
the honest label: the stop was user-initiated, just via the timer they
set rather than the stop button.

TRACES: UR-023, UR-026 | DR-029
2026-07-28 01:33:10 +02:00
dtourolle d1c01a6bc3 feat(player): defer single tap so a double tap doesn't also toggle pause
A tap cannot be classified when it lands — it may still turn out to be
the first half of a double tap. Play/pause is therefore deferred until
the 300ms double-tap window closes, and cancelled outright if a second
tap arrives, so a double tap seeks without also toggling pause.

Forward skip moves from 10s to 30s (back stays 10s), for both double tap
and the keyboard arrows.

The timing rules live in tapGestures.ts so they are unit-testable
without mounting the player. Rapid double taps now chain off a
still-in-flight seek target instead of all resolving against the same
not-yet-updated position.

TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
2026-07-28 01:33:04 +02:00
dtourolle e5d3cc06f2 fix(android): register WebView JS bridges once; stop audio-focus fight
Locking the screen killed audio on video playback even with the
background-audio toggle armed.

configureWebViewForMedia() ran from onCreate's delayed post AND from
every onResume, re-calling addJavascriptInterface on each pass — five
times in a 45s session. WebView binds injected objects at page-load
time, so re-injecting over a live page leaves JS holding a stale proxy:
the object stays truthy (passing the `bridge()?.` optional chain) while
its methods vanish. Logcat showed 66 "WebView: Unknown object" errors
and, in JS, "TypeError: setEnabled is not a function".

So the toggle turned blue but never reached native. backgroundAudioEnabled
stayed false, onStop never dispatched 'jellytau-background', the handoff
never ran, and audio stopped the instant the screen locked. PiP and audio
focus broke identically.

- Register the bridges exactly once per WebView (identity-compared), and
  split the idempotent settings/chrome-client work into
  configureWebViewSettings() so it still runs on every resume.
- Forward WebView console output to logcat as "JellyTauWeb". The frontend
  was previously invisible to adb, which is what made this bug so hard to
  place; keep it for the next boundary-spanning diagnosis.
- setBackgroundAudioEnabled now reports whether native was actually
  reached instead of silently no-oping, so a dead bridge can never again
  masquerade as an armed toggle.

Removing the re-injection revived a latent conflict it had been masking:
the focus calls started working, and three AUDIOFOCUS_GAIN requesters
inside one uid began fighting — MainActivity, ExoPlayer, and Chromium's
own AudioFocusDelegate. The grant was followed ~45ms later by
AUDIOFOCUS_LOSS, whose handler paused playback, so arming background
audio (or just pressing play) paused the video in a loop.

WebView already manages focus for <video>. Drop the redundant
AndroidAudioFocus bridge, its listeners and its helpers entirely, and
leave focus to whichever engine is actually rendering — consistent with
the player-is-authoritative principle.

Also drops the dead AndroidBackgroundAudio.isSupported() probe, unused
since the button gate moved to platform().

TRACES: UR-040 | IR-025, DR-051 | UT-062
2026-07-28 01:32:57 +02:00
14 changed files with 920 additions and 372 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -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 { }
}
}
} }
+71
View File
@@ -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 -1
View File
@@ -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",
+89 -35
View File
@@ -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);
});
});
+128
View File
@@ -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;
}
+68
View File
@@ -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();
});
});
+14 -13
View File
@@ -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;
} }
} }