Compare commits

..
10 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
dtourolle 5759a97289 chore: ignore Arch packaging build artifacts
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m49s
Build & Release / Build Linux (push) Successful in 17m46s
Build & Release / Build Windows (push) Successful in 13m20s
Build & Release / Build Android (push) Successful in 29m14s
Build & Release / Create Release (push) Successful in 15s
A local `scripts/build-arch.sh` run leaves a vendored cargo cache
(`.cargo-arch/`), a makepkg workdir (`packaging/arch/pkg/`, `src/`) and the
built package in the tree — tens of thousands of untracked files that bury real
changes in `git status`.
2026-07-25 15:53:10 +02:00
dtourolle b9f026e215 chore(release): bump to 0.1.2
Adds CHANGELOG.md, which the release-notes template in docs/release-checklist.md
already linked to but which had never been created.
2026-07-25 15:21:51 +02:00
dtourolle b7a7037194 docs: add UR-060 search relevance requirement; regenerate matrix
Records the search relevance and grouping behaviour as UR-060, with DR-090
(Rust relevance ranking) and DR-091 (Shows/Episodes split, People group,
stored-order migration). DR-066 now points at DR-091 for the current group set
instead of restating a default order that has since changed.
2026-07-25 15:13:52 +02:00
dtourolle 124da29fc7 fix(search): route the library header search to /search
Typing in the desktop header search bar ran library.search() in place and
relied on /library rendering the results inline. On every other /library/**
route nothing rendered them, so the search bar looked broken: results were
fetched and never shown.

Make /search the single surface that renders results. The header bar becomes a
navigator — it hands the query and route-derived scope to /search via ?q= and
?scope=, which seed the page and run the search on arrival. The inline result
block and the header's scope chips are removed; the chips live on /search,
which owns the results. The empty `all` scope is omitted from the URL, and
typing while already on /search does not push a history entry per keystroke.
2026-07-25 15:13:45 +02:00
dtourolle 5927299c0f feat(search): rank results by match quality and split TV/People groups
Neither search backend orders by *where* the query matched, so a mid-word hit
could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above
"Parks and Recreation".

Add `domain/search_rank.rs`, which sorts by match position (prefix →
word-start → mid-word substring → no name match), then by media kind so a
container outranks its own contents. The sort is stable, so each backend's own
relevance still breaks ties it was never overruled on. `repository_search`
applies it to both the instant cache result and the merged cache+server union,
so the list does not reshuffle when server results land. Ranking lives in Rust
because "a better match" is domain vocabulary, not presentation.

On the frontend, the combined `tvShows` result group splits into separate
Shows and Episodes groups so a show no longer competes with its own episodes
for a slot, and a People group is added so searching an actor's name reaches
their bio. A stored `tvShows` order expands in place, keeping the position an
upgrading user chose for it.
2026-07-25 15:13:32 +02:00
dtourolle 7650efcb7f fix(player): scale video to fill the player viewport
The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.

Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
2026-07-25 15:12:53 +02:00
28 changed files with 1894 additions and 542 deletions
+6
View File
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
/docs/README.md /docs/README.md
/docs/api-redirect.md /docs/api-redirect.md
/docs-site/book/ /docs-site/book/
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
/.cargo-arch/
/packaging/arch/pkg/
/packaging/arch/src/
/packaging/arch/*.pkg.tar.zst
+47
View File
@@ -0,0 +1,47 @@
# Changelog
All notable changes to JellyTau are documented here.
Entries are grouped by the capability they change, not by commit. Requirement
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
## v0.1.2
### ✨ Features
- **Search results are ordered by how well they match.** A name that *starts*
with the query now 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, so a series lands above its own episodes.
Ranking is applied to the instant cached results and to the merged
cache+server list alike, so the list no longer reshuffles when server results
arrive. (UR-060, DR-090)
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
group splits into Shows and Episodes so a show never competes with its own
episodes for a slot, and a new People group means searching an actor's name
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
Albums → Artists → People; a group order saved before the split keeps the
position it was dragged to. (UR-060, DR-091)
### 🐛 Bug Fixes
- **The library header search bar works on every library page.** It previously
searched in place and depended on `/library` rendering results inline, so on
any other `/library/**` route the results were fetched and never shown.
`/search` is now the single surface that renders results, and the header bar
hands its query and scope over via the URL. (UR-049, DR-063)
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
oversized media, so a 480p source on a 1080p display played as a small picture
in the middle of a black frame. The picture now fits whichever axis constrains
it, in both directions, preserving aspect ratio. (UR-005)
### 📋 Requirements
**Linux:** 64-bit, GLIBC 2.29+
**Android:** 8.0+
## v0.1.1 and earlier
Released before this file existed — see the git history and the release notes on
each tag.
+13 -1
View File
@@ -70,6 +70,8 @@ For a narrative overview of the system design, see
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done | | UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.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-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-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 |
--- ---
@@ -221,7 +223,7 @@ Internal architecture, components, and application logic.
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented | | DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented | | DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented | | DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented | | DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented | | DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done | | DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done | | DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
@@ -242,6 +244,9 @@ Internal architecture, components, and application logic.
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done | | DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done | | DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | 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-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-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 |
--- ---
@@ -309,6 +314,8 @@ Internal architecture, components, and application logic.
| UR-056 | - | DR-085 | | UR-056 | - | DR-085 |
| UR-057 | - | DR-086 | | UR-057 | - | DR-086 |
| UR-058 | - | DR-087 | | UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
| UR-061 | - | DR-092 |
--- ---
@@ -379,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 |
@@ -398,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
+408 -274
View File
File diff suppressed because it is too large Load Diff
+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.1", "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.1" 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.1" 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 { }
}
}
} }
+13 -2
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use uuid::Uuid; use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
use crate::repository::{ use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository, types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
@@ -409,7 +410,7 @@ pub async fn repository_search(
// Phase 1: instant local results from the cache (downloaded content) so the // Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried. // UI can render immediately while the server is still being queried.
let cache_result = repo let mut cache_result = repo
.search_cache_only(&query, options.clone()) .search_cache_only(&query, options.clone())
.await .await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
@@ -420,6 +421,12 @@ pub async fn repository_search(
} }
}); });
// Neither backend orders by *where* the query matched, so a mid-word hit
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
// Both phases are ranked with the same rules so the list does not reshuffle
// when the server results land.
rank_search_results(&mut cache_result.items, &query);
// Phase 2: query the live server in the background, merge with the cache, // Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with // and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries. // `request_id` so the frontend can discard results from superseded queries.
@@ -428,7 +435,11 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await { match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => { Ok(server_result) => {
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result); let mut merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
// Rank the union, not each half: a server-only prefix match must
// be able to outrank a cached mid-word one.
rank_search_results(&mut merged.items, &query);
let event = SearchUpdateEvent { let event = SearchUpdateEvent {
request_id, request_id,
result: merged, result: merged,
+2
View File
@@ -5,6 +5,8 @@
pub mod from_jellyfin; pub mod from_jellyfin;
pub mod media; pub mod media;
pub mod search_rank;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms}; pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind}; pub use media::{MediaKind, StreamKind};
pub use search_rank::rank_search_results;
+313
View File
@@ -0,0 +1,313 @@
//! Relevance ranking for search results.
//!
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
//! in an order that ignores *where* in the name the query matched: a server
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
//! the query "parks". Neither backend is going to change, so the app imposes its
//! own ordering on the union.
//!
//! Ranking is domain logic, not presentation: it encodes what a "better match"
//! means and which media kinds outrank which. The frontend only renders the
//! order it is given.
//!
//! Two rules, in priority order:
//!
//! 1. **Match position** — a prefix match beats a word-start match, which beats
//! a mid-word substring match. This is what makes "parks" find
//! "Parks and Recreation" before "Sparks of Love".
//! 2. **Kind** — containers before their contents at equal match quality, so a
//! series outranks its own episodes.
//!
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
//! `rank`) still breaks ties it was never overruled on.
use crate::domain::MediaKind;
use crate::repository::types::MediaItem;
/// How well a query matched an item's name — better matches sort first.
///
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
/// comparison for free, so adding a tier in the right position is all it takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchQuality {
/// The name starts with the query — "parks" in "Parks and Recreation".
Prefix,
/// Some later *word* starts with the query — "recreation" in "Parks and
/// Recreation". Still a deliberate hit: users type whole words.
WordStart,
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
/// that still counts as a match.
Substring,
/// No match on the name at all. The backend returned it for some other
/// reason (overview, artist, album), so it is kept but sorted last.
None,
}
/// Rank of a media kind when match quality ties — lower sorts first.
///
/// Containers outrank the items they contain: searching a show's name should
/// surface the show, not an arbitrary episode of it. Within a tier the order is
/// arbitrary but stable, and equal ranks fall through to input order.
fn kind_rank(kind: MediaKind) -> u8 {
match kind {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
}
// Peripheral matches.
MediaKind::Person | MediaKind::Other => 3,
}
}
/// Classify how `query` matches `name`, case-insensitively.
///
/// Both sides are trimmed and lowercased; an empty query matches everything
/// equally (`Prefix`), which leaves the input order untouched.
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
let query = query.trim().to_lowercase();
if query.is_empty() {
return MatchQuality::Prefix;
}
let name = name.trim().to_lowercase();
let Some(index) = name.find(&query) else {
return MatchQuality::None;
};
if index == 0 {
return MatchQuality::Prefix;
}
// A word start is any match preceded by a non-alphanumeric character, so
// "the-office" and "The Office" behave the same. Indexing back one char is
// safe on the byte index `find` returned only via `char_indices`, since a
// multi-byte char would panic on a raw slice.
let preceded_by_boundary = name[..index]
.chars()
.next_back()
.is_some_and(|c| !c.is_alphanumeric());
if preceded_by_boundary {
MatchQuality::WordStart
} else {
MatchQuality::Substring
}
}
/// Sort search results by relevance to `query`, in place.
///
/// Stable, so items the rules rank equally keep the order the backend supplied
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
///
/// TRACES: UR-060 | DR-090
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
// An empty query carries no relevance signal, so there is nothing to rank
// by — reordering on kind alone would shuffle the backend's own ordering
// for no reason.
if query.trim().is_empty() {
return;
}
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
}
#[cfg(test)]
mod tests {
use super::*;
fn item(name: &str, kind: MediaKind) -> MediaItem {
let mut item = MediaItem::default();
item.id = format!("id-{}-{:?}", name, kind);
item.name = name.to_string();
item.kind = kind;
item
}
fn names(items: &[MediaItem]) -> Vec<&str> {
items.iter().map(|i| i.name.as_str()).collect()
}
/// UT-085: a prefix match outranks a mid-word substring match.
#[test]
fn prefix_match_beats_midword_substring() {
assert_eq!(
match_quality("Parks and Recreation", "parks"),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Sparks of Love", "parks"),
MatchQuality::Substring
);
assert!(MatchQuality::Prefix < MatchQuality::Substring);
}
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
#[test]
fn ranks_prefix_match_before_substring_match() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Sparks of Love"]
);
}
/// A match at a later word start beats a mid-word one but loses to a prefix.
#[test]
fn word_start_ranks_between_prefix_and_substring() {
assert_eq!(
match_quality("The Office", "office"),
MatchQuality::WordStart
);
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
let mut items = vec![
item("Bofficer", MediaKind::Series),
item("The Office", MediaKind::Series),
item("Office Space", MediaKind::Movie),
];
rank_search_results(&mut items, "office");
assert_eq!(
names(&items),
vec!["Office Space", "The Office", "Bofficer"]
);
}
/// UT-086: at equal match quality a series outranks an episode.
#[test]
fn series_ranks_before_episode_at_equal_match_quality() {
let mut items = vec![
item("Parks and Recreation S01E01", MediaKind::Episode),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
);
}
/// Albums outrank their tracks for the same reason series outrank episodes.
#[test]
fn album_ranks_before_track_at_equal_match_quality() {
let mut items = vec![
item("Rumours", MediaKind::Track),
item("Rumours", MediaKind::Album),
];
rank_search_results(&mut items, "rumours");
assert_eq!(items[0].kind, MediaKind::Album);
}
/// Match quality dominates kind: a better-matching episode beats a
/// worse-matching series, so kind never drags an irrelevant show to the top.
#[test]
fn match_quality_outranks_kind() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks Cleanup", MediaKind::Episode),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
}
/// Items the backend returned for a non-name reason (overview, artist) are
/// kept, but sort below everything that actually matched the name.
#[test]
fn non_matching_names_sort_last_without_being_dropped() {
let mut items = vec![
item("Unrelated Documentary", MediaKind::Movie),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Unrelated Documentary"]
);
}
/// Ranking is stable: equally-ranked items keep the backend's order, so the
/// FTS/server relevance signal still breaks ties.
#[test]
fn equal_rank_preserves_input_order() {
let mut items = vec![
item("Parks A", MediaKind::Series),
item("Parks B", MediaKind::Series),
item("Parks C", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
}
/// Case and surrounding whitespace never change the tier.
#[test]
fn matching_is_case_and_whitespace_insensitive() {
assert_eq!(
match_quality("PARKS AND RECREATION", " parks "),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Parks and Recreation", "PARKS"),
MatchQuality::Prefix
);
}
/// An empty query leaves the order alone rather than reshuffling on kind.
#[test]
fn empty_query_preserves_input_order() {
let mut items = vec![
item("Zebra", MediaKind::Episode),
item("Apple", MediaKind::Series),
];
rank_search_results(&mut items, "");
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
}
/// A multi-byte name must not panic when the match is mid-string — the
/// boundary check walks chars rather than slicing raw bytes.
#[test]
fn handles_multibyte_names_without_panicking() {
assert_eq!(
match_quality("Pokémon Journeys", "journeys"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Café Parks", "parks"),
MatchQuality::WordStart
);
}
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
#[test]
fn punctuation_counts_as_a_word_boundary() {
assert_eq!(
match_quality("The-Office", "office"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Show: Parks", "parks"),
MatchQuality::WordStart
);
}
}
+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.1", "version": "0.1.5",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+91 -36
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";
@@ -12,6 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte"; import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte"; import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player"; import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter"; import * as html5Adapter from "$lib/player/html5Adapter";
@@ -19,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,
@@ -101,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)
@@ -702,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) {
@@ -1191,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);
} }
@@ -1341,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`,
@@ -1357,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) {
@@ -1374,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);
} }
} }
@@ -1388,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) {
@@ -1421,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)
@@ -1437,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);
@@ -1595,7 +1650,7 @@
<video <video
bind:this={videoElement} bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl} src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
class="max-w-full max-h-full" class={videoFitClass()}
class:invisible={!isMediaReady} class:invisible={!isMediaReady}
style="filter: brightness({brightness})" style="filter: brightness({brightness})"
playsinline playsinline
@@ -1611,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}
@@ -1664,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>
@@ -1675,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;
}
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
+49
View File
@@ -0,0 +1,49 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}
@@ -53,7 +53,7 @@
<MediaCard <MediaCard
{item} {item}
size="medium" size="medium"
showProgress={group.id !== "artists"} showProgress={group.id !== "artists" && group.id !== "people"}
onclick={() => onItemClick?.(item)} onclick={() => onItemClick?.(item)}
/> />
{/each} {/each}
+43 -26
View File
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
it("loads a stored order", async () => { it("loads a stored order", async () => {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"]) JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
); );
const { searchGroupOrder } = await import("./searchGroupOrder"); const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([ expect(get(searchGroupOrder)).toEqual([
"tvShows", "episodes",
"shows",
"movies", "movies",
"songs", "songs",
"albums", "albums",
"artists", "artists",
"people",
]);
});
it("migrates a stored `tvShows` from before the group split", async () => {
// Upgrading must keep the user's placement of TV, not append the two new
// groups at the bottom.
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]); ]);
}); });
@@ -59,10 +77,12 @@ describe("searchGroupOrder", () => {
const { searchGroupOrder } = await import("./searchGroupOrder"); const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([ expect(get(searchGroupOrder)).toEqual([
"movies", "movies",
"shows",
"episodes",
"songs", "songs",
"albums", "albums",
"artists", "artists",
"tvShows", "people",
]); ]);
}); });
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
it("persists a move so the order survives a restart", async () => { it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder"); const { searchGroupOrder } = await import("./searchGroupOrder");
// Default is shows, episodes, movies, songs, … — move movies up one.
searchGroupOrder.move("movies", -1); searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([ const expected = [
"shows",
"movies",
"episodes",
"songs", "songs",
"albums", "albums",
"movies",
"artists", "artists",
"tvShows", "people",
]); ];
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([ expect(get(searchGroupOrder)).toEqual(expected);
"songs", expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
"albums",
"movies",
"artists",
"tvShows",
]);
// Simulate a fresh app start reading the same storage. // Simulate a fresh app start reading the same storage.
vi.resetModules(); vi.resetModules();
const reloaded = await import("./searchGroupOrder"); const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([ expect(get(reloaded.searchGroupOrder)).toEqual(expected);
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
}); });
it("persists a drag reorder", async () => { it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder"); const { searchGroupOrder } = await import("./searchGroupOrder");
// Drag "albums" (index 4) to the front.
searchGroupOrder.reorder(4, 0); searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([ expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums", "albums",
"artists", "shows",
"episodes",
"movies", "movies",
"songs",
"artists",
"people",
]); ]);
}); });
it("resets to the shipped default", async () => { it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder"); const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1); searchGroupOrder.move("movies", -1);
searchGroupOrder.reset(); searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]); expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
}); });
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
searchGroupOrder.set(["movies", "podcasts"] as never); searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([ expect(get(searchGroupOrder)).toEqual([
"movies", "movies",
"shows",
"episodes",
"songs", "songs",
"albums", "albums",
"artists", "artists",
"tvShows", "people",
]); ]);
}); });
}); });
+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;
} }
} }
+154 -26
View File
@@ -7,6 +7,8 @@ import {
normalizeGroupOrder, normalizeGroupOrder,
reorderGroups, reorderGroups,
resolveSearchScope, resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
scopeItemTypes, scopeItemTypes,
type SearchGroupId, type SearchGroupId,
} from "./searchScope"; } from "./searchScope";
@@ -92,9 +94,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([ expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
"movies", "movies",
"songs", "songs",
"shows",
"episodes",
"albums", "albums",
"artists", "artists",
"tvShows", "people",
]); ]);
}); });
@@ -103,9 +107,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([ expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
"movies", "movies",
"songs", "songs",
"shows",
"episodes",
"albums", "albums",
"artists", "artists",
"tvShows", "people",
]); ]);
}); });
@@ -113,34 +119,78 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([ expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
"songs", "songs",
"movies", "movies",
"shows",
"episodes",
"albums", "albums",
"artists", "artists",
"tvShows", "people",
]); ]);
}); });
it("preserves a complete valid order unchanged", () => { it("preserves a complete valid order unchanged", () => {
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"]; const order: SearchGroupId[] = [
"episodes",
"shows",
"movies",
"artists",
"albums",
"songs",
"people",
];
expect(normalizeGroupOrder(order)).toEqual(order); expect(normalizeGroupOrder(order)).toEqual(order);
}); });
it("expands a stored `tvShows` into shows + episodes in place", () => {
// Migration: the old combined group split, and a user who put TV first
// must still get TV first rather than appended at the bottom.
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
}); });
describe("groupsForScope", () => { describe("groupsForScope", () => {
it("returns every group in saved order for the all scope", () => { it("returns every group in saved order for the all scope", () => {
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([ expect(
groupsForScope("all", [
"movies", "movies",
"songs", "songs",
"tvShows", "shows",
"episodes",
"albums", "albums",
"artists", "artists",
]); "people",
])
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
}); });
it("keeps only in-scope groups, in saved order", () => { it("keeps only in-scope groups, in saved order", () => {
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"]; const order: SearchGroupId[] = [
"artists",
"movies",
"albums",
"episodes",
"shows",
"songs",
"people",
];
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]); expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
expect(groupsForScope("movies", order)).toEqual(["movies"]); expect(groupsForScope("movies", order)).toEqual(["movies"]);
expect(groupsForScope("tv", order)).toEqual(["tvShows"]); expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
});
it("surfaces people only under the all scope", () => {
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
}); });
}); });
@@ -156,13 +206,29 @@ describe("composeSearchGroups", () => {
it("renders groups in the configured order", () => { it("renders groups in the configured order", () => {
const groups = composeSearchGroups(results, "all", [ const groups = composeSearchGroups(results, "all", [
"tvShows", "shows",
"episodes",
"movies", "movies",
"songs", "songs",
"albums", "albums",
"artists", "artists",
"people",
]); ]);
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]); expect(groups.map((g) => g.id)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"people",
]);
});
it("puts shows ahead of episodes by default", () => {
// Searching a show's name should surface the show itself first, not an
// arbitrary episode of it.
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
expect(ids).toEqual(["shows", "episodes"]);
}); });
it("omits empty groups", () => { it("omits empty groups", () => {
@@ -177,27 +243,44 @@ describe("composeSearchGroups", () => {
"albums", "albums",
]); ]);
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([ expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
"tvShows", "shows",
"episodes",
]); ]);
}); });
it("groups series and episodes together under tvShows", () => { it("separates series and episodes into their own groups", () => {
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER); const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]); expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
});
it("surfaces people so an actor search reaches their bio", () => {
// Person items were previously returned by the backend and silently dropped.
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
}); });
it("ignores item types that belong to no group", () => { it("ignores item types that belong to no group", () => {
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER); const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6"); const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
}); });
it("narrowing then widening restores the full arrangement", () => { it("narrowing then widening restores the full arrangement", () => {
// Scope is a filter over the saved order, never a rewrite of it. // Scope is a filter over the saved order, never a rewrite of it.
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"]; const order: SearchGroupId[] = [
"shows",
"songs",
"movies",
"albums",
"artists",
"episodes",
"people",
];
const wide = composeSearchGroups(results, "all", order).map((g) => g.id); const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
composeSearchGroups(results, "music", order); composeSearchGroups(results, "music", order);
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide); expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]); expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
}); });
it("survives a stored order containing an unknown id", () => { it("survives a stored order containing an unknown id", () => {
@@ -205,7 +288,14 @@ describe("composeSearchGroups", () => {
"podcasts", "podcasts",
"movies", "movies",
] as unknown as SearchGroupId[]); ] as unknown as SearchGroupId[]);
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]); expect(groups.map((g) => g.id)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"people",
]);
}); });
it("handles items with a missing type", () => { it("handles items with a missing type", () => {
@@ -219,7 +309,7 @@ describe("composeSearchGroups", () => {
}); });
describe("moveGroup", () => { describe("moveGroup", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"]; const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves a group up", () => { it("moves a group up", () => {
expect(moveGroup(order, "artists", -1)).toEqual([ expect(moveGroup(order, "artists", -1)).toEqual([
@@ -227,7 +317,7 @@ describe("moveGroup", () => {
"artists", "artists",
"albums", "albums",
"movies", "movies",
"tvShows", "shows",
]); ]);
}); });
@@ -237,13 +327,13 @@ describe("moveGroup", () => {
"songs", "songs",
"artists", "artists",
"movies", "movies",
"tvShows", "shows",
]); ]);
}); });
it("is a no-op at the boundaries", () => { it("is a no-op at the boundaries", () => {
expect(moveGroup(order, "songs", -1)).toEqual(order); expect(moveGroup(order, "songs", -1)).toEqual(order);
expect(moveGroup(order, "tvShows", 1)).toEqual(order); expect(moveGroup(order, "shows", 1)).toEqual(order);
}); });
it("is a no-op for an unknown id", () => { it("is a no-op for an unknown id", () => {
@@ -258,18 +348,18 @@ describe("moveGroup", () => {
}); });
describe("reorderGroups", () => { describe("reorderGroups", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"]; const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves an item from one index to another", () => { it("moves an item from one index to another", () => {
expect(reorderGroups(order, 0, 4)).toEqual([ expect(reorderGroups(order, 0, 4)).toEqual([
"albums", "albums",
"artists", "artists",
"movies", "movies",
"tvShows", "shows",
"songs", "songs",
]); ]);
expect(reorderGroups(order, 4, 0)).toEqual([ expect(reorderGroups(order, 4, 0)).toEqual([
"tvShows", "shows",
"songs", "songs",
"albums", "albums",
"artists", "artists",
@@ -283,3 +373,41 @@ describe("reorderGroups", () => {
expect(reorderGroups(order, 0, 9)).toEqual(order); expect(reorderGroups(order, 0, 9)).toEqual(order);
}); });
}); });
describe("searchRouteUrl", () => {
it("encodes the query and the scope", () => {
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
});
it("omits the scope key for the default `all` scope", () => {
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
});
it("targets bare /search for an empty query so the page shows its empty state", () => {
expect(searchRouteUrl("", "all")).toBe("/search");
expect(searchRouteUrl(" ", "music")).toBe("/search");
});
});
describe("shouldNavigateToSearch", () => {
it("navigates from any library page, which cannot render results itself", () => {
// The bug: the header search bar shows on every /library/** route but only
// /library rendered $library.searchResults, so typing did nothing on
// /library/music, /library/tv, /library/movies and detail pages.
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
});
it("stays put when already on /search, so typing does not re-push history", () => {
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
});
it("does not navigate on an empty query", () => {
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
});
});
+105 -14
View File
@@ -62,51 +62,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
return "all"; return "all";
} }
/**
* The URL of the single search surface for a query + scope.
*
* `/search` is the *only* route that renders results, so every other search
* affordance (the desktop header bar) is a navigator to this URL rather than a
* second result renderer. The `all` scope is the page's own default, so it is
* omitted to keep shared/back-navigated URLs clean.
*
* TRACES: UR-049 | DR-063
*/
export function searchRouteUrl(query: string, scope: SearchScope): string {
const trimmed = query.trim();
if (!trimmed) return "/search";
const params = new URLSearchParams({ q: trimmed });
if (scope !== "all") params.set("scope", scope);
// URLSearchParams renders spaces as "+", valid in a query but noisier to
// read; %20 is equally valid and matches how the app builds other links.
return `/search?${params.toString().replace(/\+/g, "%20")}`;
}
/**
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
*
* True for every route except `/search` itself: no other page renders
* `searchResults`, so a search performed there is invisible. Guarding on
* `/search` keeps typing from pushing a history entry per keystroke.
*
* TRACES: UR-049 | DR-063
*/
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
if (!query.trim()) return false;
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
return path !== "/search";
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Result groups // Result groups
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows"; export type SearchGroupId =
| "shows"
| "episodes"
| "movies"
| "songs"
| "albums"
| "artists"
| "people";
/** Shipped default order, per the spec. */ /**
* Shipped default order.
*
* TRACES: UR-060 | DR-091
*
* Containers lead the kinds they contain a show above its episodes, an album
* above nothing (songs are ranked separately) which matches how people search:
* you look for the show, not an arbitrary episode of it. `people` sits last as
* a peripheral match; it exists so searching an actor's name reaches their bio
* page rather than silently dropping the result.
*/
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [ export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
"shows",
"episodes",
"movies",
"songs", "songs",
"albums", "albums",
"artists", "artists",
"movies", "people",
"tvShows",
]; ];
export const GROUP_LABELS: Record<SearchGroupId, string> = { export const GROUP_LABELS: Record<SearchGroupId, string> = {
shows: "TV Shows",
episodes: "Episodes",
movies: "Movies",
songs: "Songs", songs: "Songs",
albums: "Albums", albums: "Albums",
artists: "Artists", artists: "Artists",
movies: "Movies", people: "People",
tvShows: "TV Shows",
}; };
/** Which scopes each group belongs to (`all` always includes everything). */ /**
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = { * Which scopes each group belongs to (`all` always includes everything).
*
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
* it surfaces only under All rather than being forced into one of them.
*/
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
shows: "tv",
episodes: "tv",
movies: "movies",
songs: "music", songs: "music",
albums: "music", albums: "music",
artists: "music", artists: "music",
movies: "movies", people: null,
tvShows: "tv",
}; };
/** Item types that fall into each group. */ /** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = { const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
shows: ["Series"],
episodes: ["Episode"],
movies: ["Movie"],
songs: ["Audio"], songs: ["Audio"],
albums: ["MusicAlbum"], albums: ["MusicAlbum"],
artists: ["MusicArtist"], artists: ["MusicArtist"],
movies: ["Movie"], people: ["Person"],
tvShows: ["Series", "Episode"],
}; };
export function groupItemTypes(group: SearchGroupId): string[] { export function groupItemTypes(group: SearchGroupId): string[] {
return [...GROUP_ITEM_TYPES[group]]; return [...GROUP_ITEM_TYPES[group]];
} }
/**
* Stored group ids that no longer exist, mapped to the ids that replaced them.
*
* `tvShows` was one group holding both Series and Episode; it split so a show
* can outrank its own episodes. Expanding in place preserves the position the
* user chose for it.
*
* TRACES: UR-060 | DR-091
*/
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
tvShows: ["shows", "episodes"],
};
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
if (known.has(id)) return [id as SearchGroupId];
return RETIRED_GROUP_IDS[id] ?? [];
}
/** /**
* Normalise a stored order into a usable one. * Normalise a stored order into a usable one.
* *
@@ -123,13 +208,17 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
if (Array.isArray(stored)) { if (Array.isArray(stored)) {
for (const id of stored) { for (const id of stored) {
if (typeof id !== "string" || !known.has(id)) continue; if (typeof id !== "string") continue;
const groupId = id as SearchGroupId; // Retired ids expand in place rather than being dropped, so a user who
// dragged the old combined "TV Shows" group to the top keeps TV at the
// top instead of having shows/episodes appended to the bottom.
for (const groupId of migrateGroupId(id, known)) {
if (seen.has(groupId)) continue; if (seen.has(groupId)) continue;
seen.add(groupId); seen.add(groupId);
order.push(groupId); order.push(groupId);
} }
} }
}
for (const id of DEFAULT_GROUP_ORDER) { for (const id of DEFAULT_GROUP_ORDER) {
if (!seen.has(id)) order.push(id); if (!seen.has(id)) order.push(id);
@@ -143,6 +232,8 @@ export function groupsForScope(
scope: SearchScope, scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] { ): SearchGroupId[] {
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
// only under `all` — the `=== scope` test already excludes it elsewhere.
return normalizeGroupOrder(order as SearchGroupId[]).filter( return normalizeGroupOrder(order as SearchGroupId[]).filter(
(id) => scope === "all" || GROUP_SCOPE[id] === scope (id) => scope === "all" || GROUP_SCOPE[id] === scope
); );
+20 -14
View File
@@ -6,8 +6,12 @@
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard"; import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte"; import Search from "$lib/components/Search.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte"; import {
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope"; resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
type SearchScope,
} from "$lib/utils/searchScope";
import AppHeader from "$lib/components/AppHeader.svelte"; import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte"; import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -48,18 +52,22 @@
} }
}); });
// The header bar is a *navigator*, not a second results surface: /search is
// the only route that renders searchResults, so searching here routes there
// with the query + route-derived scope in the URL. Previously this ran
// library.search() in place, which was invisible on every /library/** page
// except /library itself.
// TRACES: UR-049 | DR-063
async function handleSearch(query: string) { async function handleSearch(query: string) {
if (query.trim()) { if (!query.trim()) {
await library.search(query, searchScope);
} else {
library.clearSearch(); library.clearSearch();
return;
} }
} if (shouldNavigateToSearch($page.url.pathname, query)) {
await goto(searchRouteUrl(query, searchScope));
async function handleScopeChange(next: SearchScope) { // The query now lives in the URL; clear the header input so returning to
searchScope = next; // a library page does not leave a stale term sitting in the box.
if (searchQuery.trim()) { searchQuery = "";
await library.search(searchQuery, next);
} }
} }
</script> </script>
@@ -74,14 +82,12 @@
<AppHeader search={librarySearch} /> <AppHeader search={librarySearch} />
{#snippet librarySearch()} {#snippet librarySearch()}
<!-- Scope chips live on /search, which owns the results. -->
<Search <Search
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Search your library..." placeholder="Search your library..."
onSearch={handleSearch} onSearch={handleSearch}
/> />
{#if searchQuery.trim()}
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
{/if}
{/snippet} {/snippet}
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this <!-- Main content. The BottomUi below is an in-flow flex sibling, so this
+4 -24
View File
@@ -12,8 +12,9 @@
// Scroll guard from layout - prevents accidental taps during scrolling (Android) // Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard"); const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
let searchResults = $derived($library.searchResults); // Search results are rendered exclusively by /search — this page used to
let searchQuery = $derived($library.searchQuery); // render them inline, which made the header search bar appear broken on every
// other /library/** route. TRACES: UR-049 | DR-063
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music"); const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
@@ -169,28 +170,7 @@
</script> </script>
<div class="space-y-8"> <div class="space-y-8">
{#if searchQuery} {#if showInlineLibraryContent}
<!-- Search results -->
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-white">
Search results for "{searchQuery}"
</h1>
<button
onclick={() => library.clearSearch()}
class="text-sm text-gray-400 hover:text-white"
>
Clear search
</button>
</div>
<LibraryGrid
items={searchResults}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else if showInlineLibraryContent}
<!-- Library content (live TV / channels / other inline-rendered types) --> <!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
+29 -3
View File
@@ -5,15 +5,41 @@
import Search from "$lib/components/Search.svelte"; import Search from "$lib/components/Search.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte"; import SearchResults from "$lib/components/search/SearchResults.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte"; import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope"; import { resolveSearchScope, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
let searchQuery = $state(""); // `?q=` / `?scope=` seed the page so the desktop header search bar can hand
// a query over by navigating here — /search is the only surface that renders
// results, so every other search affordance routes into it.
// TRACES: UR-049 | DR-063
const initialQuery = $page.url.searchParams.get("q") ?? "";
const initialScope = $page.url.searchParams.get("scope");
let searchQuery = $state(initialQuery);
// Route resolves the *initial* scope only. Deriving it reactively would snap // Route resolves the *initial* scope only. Deriving it reactively would snap
// a user who widened to All back to the route's scope on any navigation. // a user who widened to All back to the route's scope on any navigation.
// TRACES: UR-049 | DR-064 // TRACES: UR-049 | DR-064
let scope = $state<SearchScope>(resolveSearchScope($page.url.pathname)); let scope = $state<SearchScope>(
SEARCH_SCOPES.includes(initialScope as SearchScope)
? (initialScope as SearchScope)
: resolveSearchScope($page.url.pathname)
);
// A query arriving in the URL must actually run — mounting with a seeded
// input alone would render the empty state with a filled box.
$effect(() => {
const q = $page.url.searchParams.get("q") ?? "";
if (!q.trim()) return;
const urlScope = $page.url.searchParams.get("scope");
const nextScope = SEARCH_SCOPES.includes(urlScope as SearchScope)
? (urlScope as SearchScope)
: "all";
if (q === $library.searchQuery && nextScope === scope) return;
searchQuery = q;
scope = nextScope;
library.search(q, nextScope);
});
async function handleSearch(query: string) { async function handleSearch(query: string) {
if (query.trim()) { if (query.trim()) {