Compare commits

..
15 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
dtourolle 4b9350c949 chore(release): bump to 0.1.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m25s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 3m52s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m52s
Build & Release / Build Linux (push) Successful in 17m32s
Build & Release / Build Windows (push) Successful in 13m14s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 13s
2026-07-25 09:25:52 +02:00
dtourolle d01c1216b8 docs: red-green rule for bug fixes; regenerate traceability matrix
CLAUDE.md now states the failing-test-first rule explicitly: write a test
that reproduces the bug and watch it fail before applying the fix, and
extract buried logic into a plain .ts module so it can be unit-tested. A
test written against already-fixed code can pass for the wrong reason.
2026-07-25 09:21:22 +02:00
dtourolle fb967433f0 fix(library): populate "More Episodes" for series without season folders
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
2026-07-25 09:21:15 +02:00
dtourolle ee584aced2 fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a
MediaType::Audio item, so autoplay's video-only checks stopped
recognising it as an episode: playback simply ended at the episode
boundary instead of continuing to the next one.

- Carry episode identity (item_type, series_id) through the
  background-audio handoff so the backend queue item still knows it's an
  episode; is_episode_item now trusts item_type over the media_type
  heuristic, and the sleep timer's episode counter follows.
- The frontend normally performs the advance by navigating to
  /player/<id>, which is unavailable while the WebView is suspended.
  advance_to_next_episode_audio_only drives it entirely in the backend:
  fetch the next episode, build its audio-only stream URL, and load it
  into the native audio player, preserving episode identity so the
  following boundary advances too.
- Android's autoplay dispatch routes background-audio episodes to that
  backend advance and keeps the countdown path for the foreground.
- get_audio_only_stream_url_for_video joins the MediaRepository trait
  (online delegates to the existing builder, offline errors) so the
  controller can reach it without a frontend round-trip.

TRACES: UR-040, UR-023 | DR-052 | JA-032
2026-07-25 09:21:08 +02:00
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played 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 can't overwrite the 100% progress with the partial one.
- Continue Watching drops 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 hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00
47 changed files with 3109 additions and 823 deletions
+6
View File
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
/docs/README.md
/docs/api-redirect.md
/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.
+17
View File
@@ -266,6 +266,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
## Testing
### 🔴 Bug fixes: failing test FIRST, then the fix
When fixing a bug, **write a test that reproduces it and watch it fail before
touching the fix.** Red → green, in that order:
1. Write a test that exercises the broken behavior and **run it — it must fail**,
proving the test actually catches the bug (a test that passes before the fix
proves nothing).
2. Apply the fix.
3. Re-run — the test now passes, and so does the rest of the suite.
Never fix first and backfill the test afterward: a test written against
already-fixed code can pass for the wrong reason and silently fails to guard the
regression. If the logic is buried in a component, extract the pure part into a
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
pattern as `TrackList.logic.test.ts`.
```bash
# Rust
cd src-tauri && cargo test
+16 -1
View File
@@ -69,6 +69,9 @@ For a narrative overview of the system design, see
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.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-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 |
---
@@ -220,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-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-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-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 |
@@ -239,6 +242,11 @@ Internal architecture, components, and application logic.
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | 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-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 |
---
@@ -306,6 +314,8 @@ Internal architecture, components, and application logic.
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
| UR-061 | - | DR-092 |
---
@@ -376,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-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-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-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 |
@@ -395,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-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-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
+689 -477
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -346,10 +346,12 @@ flowchart TB
**User Interaction:**
- **Tap screen:** Controls reappear for 3 seconds
- **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 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 F:** Toggle fullscreen
- **Pinch:** Zoom (planned)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.1.0",
"version": "0.1.5",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.1.0"
version = "0.1.5"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.1.0"
version = "0.1.5"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -1,10 +1,5 @@
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.Handler
import android.os.Looper
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
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.
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
*/
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?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun requestAudioFocus() {
handler.post { this@MainActivity.requestAudioFocus() }
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
//
// configureWebViewForMedia() runs from onCreate's delayed post AND from
// every onResume (plus each WebView re-find), so this used to re-inject
// 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
fun abandonAudioFocus() {
handler.post { this@MainActivity.abandonAudioFocus() }
}
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
// requests from the WebView competed with Chromium's own
// AudioFocusDelegate and with ExoPlayer, and the resulting
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
// in configureWebViewSettings().
// Add JavaScript interface for picture-in-picture control.
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
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")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
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
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
super.onHideCustomView()
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")
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
video.volume = 1.0;
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
// Add event listeners to manage audio focus
video.addEventListener('play', function() {
console.log('[Android] Video play event - requesting audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.requestAudioFocus();
}
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
});
video.addEventListener('pause', function() {
console.log('[Android] Video pause event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('ended', function() {
console.log('[Android] Video ended event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
// NOTE: deliberately no audio-focus calls here.
//
// WebView already manages audio focus for <video> through
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
// again from MainActivity made two requesters compete inside one
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
// (~45ms), whose handler paused playback - so arming background
// audio, or simply pressing play, paused the video in a loop.
//
// ExoPlayer is the third potential owner and stays authoritative
// for native playback (JellyTauPlayer manages its own focus).
// Leave focus to whichever engine is actually rendering.
video.addEventListener('volumechange', function() {
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
});
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
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
@@ -205,6 +205,15 @@ pub struct PlayItemRequest {
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
#[serde(default)]
pub duration_seconds: Option<f64>,
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
/// background-audio handoff so an episode played as audio-only is still
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
#[serde(default)]
pub item_type: Option<String>,
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
/// look up the next episode when a background-audio track ends.
#[serde(default)]
pub series_id: Option<String>,
}
/// Queue context for remote transfer - what type of queue is this?
@@ -601,7 +610,9 @@ pub async fn player_enter_background_audio(
artists: None,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
item_type: None,
// Carry episode identity so autoplay can advance to the next episode when
// this audio-only handoff ends while backgrounded (UR-040).
item_type: item.item_type.clone(),
playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
@@ -616,7 +627,7 @@ pub async fn player_enter_background_audio(
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
series_id: item.series_id.clone(),
server_id: item.server_id.clone(),
};
+13 -2
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
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
// 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())
.await
.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,
// and push the union to the frontend via a `search-event`. Tagged with
// `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 {
match repo_bg.search_server_only(&query, options).await {
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 {
request_id,
result: merged,
+2
View File
@@ -5,6 +5,8 @@
pub mod from_jellyfin;
pub mod media;
pub mod search_rank;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
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
);
}
}
+29 -1
View File
@@ -858,14 +858,42 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
});
}
// Start countdown if auto_advance enabled
if auto_advance {
// Background audio-only episode: the frontend that normally
// performs the advance (goto /player/<id>) is suspended, so
// the backend must load the next episode's audio-only stream
// itself — otherwise playback just stops at the boundary.
let is_bg_audio_episode =
controller.lock().await.current_is_audio_episode();
if is_bg_audio_episode {
log::info!(
"[Autoplay] Background audio episode — advancing to {} in backend",
next_episode.id
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
log::error!(
"[Autoplay] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else {
ctrl.emit_queue_changed();
}
} else {
// Foreground: frontend drives the advance off the countdown.
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
}
}
}
Err(e) => {
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
+271 -10
View File
@@ -243,6 +243,23 @@ impl PlayerController {
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.
fn increment_autoplay_count(&self) -> bool {
let max = self.autoplay_settings.lock_safe().max_episodes;
@@ -658,6 +675,24 @@ impl PlayerController {
self.queue.clone()
}
/// True when the current item is a TV episode being played in audio-only
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
/// `MediaType::Audio`. Used to decide whether the backend must drive the
/// next-episode advance itself (the frontend is suspended in the background).
///
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn current_is_audio_episode(&self) -> bool {
self.queue
.lock_safe()
.current()
.map(|item| {
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
})
.unwrap_or(false)
}
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
@@ -749,6 +784,7 @@ impl PlayerController {
let sleep_timer = self.sleep_timer.clone();
let event_emitter = self.event_emitter.clone();
let backend = self.backend.clone();
let end_reason = self.end_reason.clone();
std::thread::spawn(move || {
loop {
@@ -765,6 +801,14 @@ impl PlayerController {
debug!("[SleepTimer] Time-based timer expired, stopping playback");
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
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
@@ -963,9 +1007,11 @@ impl PlayerController {
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
// Only count TV episodes (not audio tracks or movies)
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).await;
// Only count TV episodes (not audio tracks or movies). Note an
// episode played in background-audio mode is MediaType::Audio, so
// rely on is_episode_item (which checks item_type) rather than the
// media_type alone.
let is_episode = self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
@@ -981,10 +1027,12 @@ impl PlayerController {
}
}
// For video episodes, fetch next episode and show popup
// For episodes, fetch next episode and show popup.
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
// It's here for the Android ExoPlayer path where episode items sit in the
// backend queue — including background-audio mode, where the episode is a
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
if self.is_episode_item(&current).await {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
@@ -1042,6 +1090,77 @@ impl PlayerController {
}
}
/// Advance to the next episode while playing audio-only in the background.
///
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
/// which is unavailable when the app is backgrounded and the WebView is
/// suspended. This drives the advance entirely in the backend: build the next
/// episode's *audio-only* stream URL and load it into the native audio player,
/// so playback continues without any frontend involvement (UR-040).
///
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
///
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
/// TRACES: UR-040, UR-023 | DR-052
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
) -> Result<(), String> {
let repo = self
.repository
.lock_safe()
.clone()
.ok_or_else(|| "No repository for background episode advance".to_string())?;
// Details for session metadata (title/series/artwork) and the stream URL.
let next = repo
.get_item(next_episode_id)
.await
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
// Audio-only transcode from the start of the episode (no resume offset —
// a freshly-started next episode always plays from the beginning).
let stream_url = repo
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
.await
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
let media_item = MediaItem {
id: next.id.clone(),
title: next.name.clone(),
name: Some(next.name.clone()),
artist: next.series_name.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: next.primary_image_tag.clone(),
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
// Preserve episode identity so the NEXT end-of-track also advances.
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url,
jellyfin_item_id: next.id.clone(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: next.series_id.clone(),
server_id: Some(next.server_id.clone()),
};
self.play_item(media_item).map_err(|e| e.to_string())
}
/// Handle video playback ended from HTML5 video element.
///
/// HTML5 video plays independently of the Rust backend, so the backend
@@ -1135,11 +1254,19 @@ impl PlayerController {
Ok(AutoplayDecision::Stop)
}
/// Check if a media item is an episode (has Jellyfin ID to query)
/// Check if a media item is an episode (has Jellyfin ID to query).
///
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
/// to the audio path for background playback (UR-040) is still recognised as
/// an episode — otherwise autoplay would fall through to the queue-based
/// audio path, find nothing next, and stop at the episode boundary. When the
/// type is unknown we fall back to the historical heuristic (video == episode).
async fn is_episode_item(&self, item: &MediaItem) -> bool {
// For now, assume video items are episodes
// In production, we'd check item metadata or query Jellyfin
item.media_type == MediaType::Video
match item.item_type.as_deref() {
Some("Episode") => true,
Some(_) => item.media_type == MediaType::Video,
None => item.media_type == MediaType::Video,
}
}
/// Fetch next episode for a series by looking up the season's episodes
@@ -1939,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]
async fn test_empty_queue_stops() {
let controller = PlayerController::default();
@@ -2311,6 +2483,15 @@ mod tests {
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, repo_types::RepoError> {
Ok(format!("http://example.com/{}-audio.mp3", item_id))
}
async fn get_live_tv_channels(
&self,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
@@ -2503,6 +2684,86 @@ mod tests {
}
}
/// Background audio-only mode (UR-040): a video episode is handed off to the
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
/// playing while the app is backgrounded. When that audio track ends, autoplay
/// must STILL recognise it as an episode and offer the next one — otherwise
/// playback just pauses at the episode boundary (the reported bug). The item
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
#[tokio::test]
async fn test_playback_ended_background_audio_episode_advances() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
let episode = MediaItem {
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, // audio-only handoff, not Video
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end.
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep3");
}
other => panic!(
"background-audio episode end must advance to the next episode, got {:?}",
other
),
}
}
/// The backend-driven advance (used when backgrounded) must load the next
/// episode as an AUDIO item carrying its episode identity, so the *following*
/// end-of-track also advances rather than stopping.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should be loaded");
assert_eq!(current.id, "ep2");
assert_eq!(current.media_type, MediaType::Audio);
assert_eq!(current.item_type.as_deref(), Some("Episode"));
assert_eq!(current.series_id.as_deref(), Some("series1"));
// Uses the audio-only URL, not a video stream.
match &current.source {
MediaSource::Remote { stream_url, .. } => {
assert!(
stream_url.contains("audio"),
"expected audio-only URL, got {}",
stream_url
);
}
other => panic!("expected Remote source, got {:?}", other),
}
// The controller now considers itself mid background-audio episode, so the
// next end-of-track will advance again rather than stop.
assert!(controller.current_is_audio_episode());
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
+38
View File
@@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode of a video requires the server - delegate to online.
self.online
.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
@@ -1028,6 +1046,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
@@ -1276,6 +1304,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
+16
View File
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// Used when autoplay advances to the next episode while the app is playing a
/// video in audio-only mode in the background: the backend needs the next
/// episode's audio-only URL without any frontend round-trip. Online-only;
/// offline/cache repositories return an error.
///
/// TRACES: UR-040 | JA-032
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
+11
View File
@@ -1518,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode requires the server; offline downloads play locally.
Err(RepoError::Offline)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
+17 -1
View File
@@ -450,7 +450,7 @@ impl OnlineRepository {
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn get_audio_only_stream_url_for_video(
pub async fn build_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
@@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
Ok(url)
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
self.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.1.0",
"version": "0.1.5",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+12 -1
View File
@@ -2055,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
* handoff so the lockscreen MediaSession advertises a real duration a
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
*/
durationSeconds?: number | null }
durationSeconds?: number | null;
/**
* Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
* background-audio handoff so an episode played as audio-only is still
* recognised as an episode by autoplay (UR-040) and advances to the next one.
*/
itemType?: string | null;
/**
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
* look up the next episode when a background-audio track ends.
*/
seriesId?: string | null }
/**
* Queue context for remote transfer - what type of queue is this?
*/
@@ -4,6 +4,7 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -14,63 +15,12 @@
let { episode, series, allEpisodes, onBack }: Props = $props();
// Check if an episode matches the focused episode (by ID or season/episode number)
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
// Also match by season/episode number in case IDs differ
return ep.parentIndexNumber === episode.parentIndexNumber &&
ep.indexNumber === episode.indexNumber;
return isSameEpisode(ep, episode);
}
// Find adjacent episodes - use season/episode numbers if ID not found
const adjacentEpisodes = $derived(() => {
// First, try to find the episode by ID
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
// If not found by ID, try to find by season/episode number
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
idx = allEpisodes.findIndex(
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
);
}
// If still not found, filter to same season and show those centered around the episode number
if (idx === -1) {
const sameSeasonEpisodes = allEpisodes
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
if (sameSeasonEpisodes.length > 0) {
// Find position based on episode number
const epNum = episode.indexNumber || 1;
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
const start = Math.max(0, actualIdx - 3);
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
const result = sameSeasonEpisodes.slice(start, end);
// Insert the focused episode if not already present (by season/episode number match)
const hasCurrentEpisode = result.some(isCurrentEpisode);
if (!hasCurrentEpisode) {
// Insert at correct position based on episode number
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
if (insertIdx === -1) {
result.push(episode);
} else {
result.splice(insertIdx, 0, episode);
}
}
return result;
}
// Last resort: return focused episode with first 9 episodes
return [episode, ...allEpisodes.slice(0, 9)];
}
// Get 3 before and 6 after (or adjust based on position)
const start = Math.max(0, idx - 3);
const end = Math.min(allEpisodes.length, idx + 7);
return allEpisodes.slice(start, end);
});
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
id: string,
season: number | null,
number: number | null,
): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
parentIndexNumber: season,
indexNumber: number,
} as unknown as MediaItem;
}
function season(n: number, count: number): MediaItem[] {
return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1));
}
describe("isCurrentEpisode", () => {
const current = ep("abc", 1, 3);
it("matches by id", () => {
expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true);
});
it("matches by season+episode number when id differs", () => {
expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true);
});
it("does not match a different episode number", () => {
expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false);
});
it("does NOT treat two number-less episodes as the same (the reported bug)", () => {
const a = ep("a", null, null);
const b = ep("b", null, null);
expect(isCurrentEpisode(a, b)).toBe(false);
});
it("does not match when only one side has numbers", () => {
expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false);
expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false);
});
});
describe("adjacentEpisodes", () => {
it("returns just the current episode when there are no others", () => {
const current = ep("only", 1, 1);
expect(adjacentEpisodes(current, [])).toEqual([current]);
});
it("returns siblings, not just the current episode", () => {
const eps = season(1, 8);
const current = eps[2]; // S1E3
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
expect(strip).toContain(current);
});
it("windows to 3 before and 6 after the current episode", () => {
const eps = season(1, 20);
const current = eps[9]; // S1E10, index 9
const strip = adjacentEpisodes(current, eps);
// start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items)
expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
const eps = season(1, 5);
// Focused episode has a different id than any in the list but same numbers.
const current = ep("fetched-directly", 1, 3);
const strip = adjacentEpisodes(current, eps);
// It should appear once, anchored at its numeric position, alongside siblings.
expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1);
expect(strip.length).toBeGreaterThan(1);
});
it("falls back to the full list when the current season is unknown", () => {
const eps = season(1, 5);
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
});
});
@@ -0,0 +1,61 @@
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
//
// Extracted from the component so it can be unit-tested: the strip must never
// collapse to just the current episode while real siblings exist, and it must
// not mistake number-less episodes for the current one.
//
// TRACES: UR-048 | DR-062
import type { MediaItem } from "$lib/api/types";
/**
* Does `ep` refer to the same episode as `current`?
*
* Matches by id first. Falls back to season+episode number, but ONLY when both
* numbers are known on both sides otherwise `undefined === undefined` would
* mark every number-less episode as the current one (the bug that made the
* whole strip look like the current episode).
*/
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
);
}
/**
* The window of episodes shown under the hero: up to 3 before and 6 after the
* current episode. Degrades gracefully:
* - prefers the current season, falling back to the full list when the season
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
* - splices the current episode into the pool at its numeric position when it
* isn't present, so it still anchors the window;
* - returns just `[current]` only when there genuinely are no other episodes.
*/
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
const seasonMatches = allEpisodes.filter(
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
);
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
.slice()
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
const epNum = current.indexNumber ?? 0;
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
idx = insertAt === -1 ? pool.length : insertAt;
pool.splice(idx, 0, current);
}
const start = Math.max(0, idx - 3);
const end = Math.min(pool.length, idx + 7);
return pool.slice(start, end);
}
+95 -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">
import { onMount, onDestroy, untrack } from "svelte";
import { goto } from "$app/navigation";
@@ -12,6 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -19,6 +20,14 @@
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
createTapGestureState,
registerTap,
resolveSeekTarget,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
} from "./tapGestures";
import {
setBackgroundAudioEnabled,
subscribeAppBackgrounded,
@@ -101,11 +110,14 @@
let touchStartX = $state(0);
let touchStartY = $state(0);
let touchStartTime = $state(0);
let lastTapTime = $state(0);
let tapGestures = createTapGestureState();
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
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;
// 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);
// Backend info from Rust (Rust decides which backend to use based on platform)
@@ -702,6 +714,16 @@
if (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)
for (const unlisten of nativeUnlisteners) {
@@ -1191,9 +1213,13 @@
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
// 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);
}
@@ -1233,6 +1259,10 @@
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
// Episode identity so the backend can auto-advance to the next episode
// when this audio-only stream ends while backgrounded (UR-040).
itemType: media.type ?? null,
seriesId: media.seriesId ?? null,
},
pos,
);
@@ -1337,7 +1367,16 @@
async function seekRelative(seconds: number) {
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:", {
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
@@ -1353,7 +1392,12 @@
}
} as unknown as Event;
try {
await handleSeekBarChange(syntheticEvent);
} finally {
// The player is authoritative again from here on.
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
}
}
function handleKeydown(e: KeyboardEvent) {
@@ -1370,10 +1414,10 @@
}
} else if (e.key === "ArrowLeft") {
e.preventDefault();
seekRelative(-10);
seekRelative(SEEK_BACKWARD_SECONDS);
} else if (e.key === "ArrowRight") {
e.preventDefault();
seekRelative(10);
seekRelative(SEEK_FORWARD_SECONDS);
}
}
@@ -1384,25 +1428,31 @@
touchStartY = touch.clientY;
touchStartTime = Date.now();
const now = Date.now();
const timeSinceLastTap = now - lastTapTime;
const outcome = registerTap(tapGestures, {
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) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
} else {
lastTapTime = now;
// Set timeout to clear if no second tap
tapTimeout = setTimeout(() => {
lastTapTime = 0;
}, 300);
if (outcome.action === "seek") {
e.preventDefault();
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
return;
}
// 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) {
@@ -1417,6 +1467,13 @@
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
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
swipeType = "brightness";
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
@@ -1433,20 +1490,22 @@
swipeType = null;
}
function handleDoubleTap(x: number) {
const screenWidth = window.innerWidth;
const isLeftSide = x < screenWidth / 2;
if (isLeftSide) {
// Double tap left: rewind 10 seconds
seekRelative(-10);
showDoubleTapFeedback = "left";
} else {
// Double tap right: forward 10 seconds
seekRelative(10);
showDoubleTapFeedback = "right";
/**
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
* by `handleTouchStart` (which defers play/pause past the double-tap window),
* so the compatibility click that follows a tap must be ignored here —
* otherwise it pauses on the first tap of a double tap.
*/
function handleVideoClick(e: MouseEvent) {
// A click synthesized from a touch reports no pointer movement detail.
if (e.detail === 0 || tapTimeout !== null) return;
togglePlayPause();
}
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
seekRelative(seekSeconds);
showDoubleTapFeedback = feedback;
// Hide feedback after animation
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
@@ -1591,7 +1650,7 @@
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
class="max-w-full max-h-full"
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
@@ -1607,7 +1666,7 @@
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={togglePlayPause}
onclick={handleVideoClick}
>
<!-- Temporarily disabled to debug playback issues
{#each subtitleTracks() as track}
@@ -1660,7 +1719,7 @@
<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">
<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>
</div>
</div>
@@ -1671,7 +1730,7 @@
<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">
<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>
</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
{item}
size="medium"
showProgress={group.id !== "artists"}
showProgress={group.id !== "artists" && group.id !== "people"}
onclick={() => onItemClick?.(item)}
/>
{/each}
+88
View File
@@ -0,0 +1,88 @@
/**
* Skip-to-next-episode reporting tests.
*
* Regression: pressing "skip to next episode" left the outgoing episode with a
* mid-episode resume position, so it showed a partial progress bar and offered
* to resume. A manual skip means the user is done with that episode it must
* be recorded as fully watched.
*
* TRACES: UR-059, UR-025 | DR-088
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const markAsPlayed = vi.fn(async (_itemId: string) => undefined);
const reportPlaybackStopped = vi.fn(
async (_itemId: string, _positionSeconds: number) => undefined
);
vi.mock("./playbackReporting", () => ({
markAsPlayed: (itemId: string) => markAsPlayed(itemId),
reportPlaybackStopped: (itemId: string, positionSeconds: number) =>
reportPlaybackStopped(itemId, positionSeconds),
}));
import {
shouldSuppressStopReport,
markSkipped,
reportSkippedEpisode,
resetSkipState,
} from "./skipReporting";
describe("skip reporting", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSkipState();
});
describe("reportSkippedEpisode", () => {
it("marks the skipped episode as fully played", async () => {
await reportSkippedEpisode("ep-1");
expect(markAsPlayed).toHaveBeenCalledWith("ep-1");
});
it("does not stamp the mid-episode position as a resume point", async () => {
await reportSkippedEpisode("ep-1");
expect(reportPlaybackStopped).not.toHaveBeenCalled();
});
it("ignores a null item id", async () => {
await reportSkippedEpisode(null);
expect(markAsPlayed).not.toHaveBeenCalled();
});
});
describe("shouldSuppressStopReport", () => {
it("suppresses the unmount stop report for the skipped episode", async () => {
await reportSkippedEpisode("ep-1");
// VideoPlayer.onDestroy fires after navigation with the mid-episode time.
expect(shouldSuppressStopReport("ep-1")).toBe(true);
});
it("only suppresses the episode that was actually skipped", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-2")).toBe(false);
});
it("suppresses only once, so a later real stop still reports", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-1")).toBe(true);
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress when nothing was skipped", () => {
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress a null item id", () => {
markSkipped("ep-1");
expect(shouldSuppressStopReport(null)).toBe(false);
});
});
});
+64
View File
@@ -0,0 +1,64 @@
// Skip-to-next-episode reporting.
//
// Skipping an episode is a "done with it" signal, not a "stopped here" one:
// the user is moving on because they've already seen it. So a manual skip
// records the outgoing episode as fully played rather than saving the
// mid-episode position as a resume point.
//
// The suppression handshake exists because VideoPlayer.onDestroy fires its
// final reportStop *after* the skip navigation, with the mid-episode time. If
// that landed, it would overwrite the just-written 100% progress and the
// episode would look partially watched again. markSkipped() arms a one-shot
// suppression that the stop handler consumes.
//
// TRACES: UR-059, UR-025 | DR-088
import { markAsPlayed, reportPlaybackStopped } from "./playbackReporting";
/** Item id whose next stop report should be dropped, if any. */
let suppressedItemId: string | null = null;
/**
* Arm suppression of the next stop report for `itemId`.
*
* Exported separately from `reportSkippedEpisode` so callers that already
* handled their own reporting can still silence the unmount stop.
*/
export function markSkipped(itemId: string | null): void {
if (!itemId) return;
suppressedItemId = itemId;
}
/**
* Should the pending stop report for `itemId` be dropped?
*
* One-shot: consumes the armed suppression, so a later genuine stop on the
* same episode still reports its position normally.
*/
export function shouldSuppressStopReport(itemId: string | null): boolean {
if (!itemId) return false;
if (suppressedItemId !== itemId) return false;
suppressedItemId = null;
return true;
}
/**
* Record a manually skipped episode as fully watched.
*
* Deliberately does NOT call `reportPlaybackStopped` that would write the
* partial position we are trying to avoid.
*/
export async function reportSkippedEpisode(itemId: string | null): Promise<void> {
if (!itemId) return;
markSkipped(itemId);
await markAsPlayed(itemId);
}
/** Test hook: clear armed suppression between cases. */
export function resetSkipState(): void {
suppressedItemId = null;
}
// Re-exported so the module owns the full skip story; callers that need the
// normal stop path keep importing it from playbackReporting directly.
export { reportPlaybackStopped };
@@ -0,0 +1,124 @@
/**
* Continue Watching stale-entry suppression tests.
*
* A partially-watched episode should drop off Continue Watching once the user
* has moved past it i.e. when Next Up for that series points at a *later*
* episode. Otherwise skipping an episode leaves it lingering as a resume
* suggestion behind the episode the user is actually on.
*
* TRACES: UR-059 | DR-089
*/
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
function episode(
id: string,
seriesId: string,
season: number | undefined,
index: number | undefined
): MediaItem {
return {
id,
name: `Episode ${index}`,
kind: "episode",
seriesId,
parentIndexNumber: season,
indexNumber: index,
} as MediaItem;
}
function movie(id: string): MediaItem {
return { id, name: "A Movie", kind: "movie" } as MediaItem;
}
describe("filterSupersededResumeItems", () => {
it("drops a partially-watched episode when next up is later in the same season", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result).toEqual([]);
});
it("drops it when next up is in a later season", () => {
const resume = [episode("s1e9", "series-a", 1, 9)];
const nextUp = [episode("s2e1", "series-a", 2, 1)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("keeps the episode the user is actually mid-way through", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps an episode ahead of next up (user jumped forward)", () => {
const resume = [episode("s1e7", "series-a", 1, 7)];
const nextUp = [episode("s1e3", "series-a", 1, 3)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("only compares within the same series", () => {
const resume = [episode("a-s1e2", "series-a", 1, 2)];
const nextUp = [episode("b-s1e9", "series-b", 1, 9)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("never suppresses movies", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps items when ordering is unknown on either side", () => {
const resume = [episode("s1e2", "series-a", undefined, undefined)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("treats a missing season number as season 1 only when both sides agree", () => {
// Flat series (no season folders): episode numbers alone must still order.
const resume = [episode("e2", "series-a", undefined, 2)];
const nextUp = [episode("e6", "series-a", undefined, 6)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("is a no-op when next up is empty", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
expect(filterSupersededResumeItems(resume, [])).toHaveLength(1);
});
it("preserves the original order of surviving items", () => {
const resume = [
episode("a-s1e2", "series-a", 1, 2),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s1e3", "series-c", 1, 3),
];
const nextUp = [episode("b-s1e4", "series-b", 1, 4)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
});
it("uses the furthest-ahead next-up entry for a series", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [
episode("s1e1", "series-a", 1, 1),
episode("s1e8", "series-a", 1, 8),
];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
// Continue Watching stale-entry suppression.
//
// Continue Watching is built from raw resume positions, so an episode the user
// has moved past keeps showing up as a resume suggestion — most visibly after
// skipping an episode, which leaves a partial position behind. Next Up already
// tells us where the user actually is in each series, so an in-progress episode
// that sits *behind* its series' Next Up entry is stale and gets suppressed.
//
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
import type { MediaItem } from "$lib/api/types";
/**
* Position of an episode within its series, as (season, episode).
*
* Returns null when the episode number is unknown without it there is no
* defensible ordering and we must not suppress anything. A missing *season*
* number is normal for flat series (no season folders), so it is only usable
* when both sides are equally season-less; callers compare via `isAheadOf`.
*/
function episodeOrder(item: MediaItem): { season: number | null; index: number } | null {
if (item.indexNumber == null) return null;
return { season: item.parentIndexNumber ?? null, index: item.indexNumber };
}
/** Is `a` strictly later in series order than `b`? */
function isAheadOf(a: MediaItem, b: MediaItem): boolean {
const oa = episodeOrder(a);
const ob = episodeOrder(b);
if (!oa || !ob) return false;
// Mixed season-numbering (one side foldered, the other flat) is not safely
// comparable — leave the entry alone rather than hide something wrongly.
if ((oa.season == null) !== (ob.season == null)) return false;
if (oa.season != null && ob.season != null && oa.season !== ob.season) {
return oa.season > ob.season;
}
return oa.index > ob.index;
}
/**
* Drop resume entries the user has already moved past.
*
* An episode is suppressed when its series has a Next Up entry strictly later
* in series order. Movies, items without a series, and anything whose ordering
* is unknown are always kept suppression must never hide something the user
* genuinely still wants to resume.
*/
export function filterSupersededResumeItems(
resumeItems: MediaItem[],
nextUpItems: MediaItem[]
): MediaItem[] {
if (nextUpItems.length === 0) return resumeItems;
// Furthest-ahead Next Up entry per series: Next Up can carry more than one
// entry for a series, and the latest is the true watch frontier.
const frontier = new Map<string, MediaItem>();
for (const item of nextUpItems) {
if (!item.seriesId) continue;
const current = frontier.get(item.seriesId);
if (!current || isAheadOf(item, current)) {
frontier.set(item.seriesId, item);
}
}
return resumeItems.filter(item => {
if (item.kind !== "episode" || !item.seriesId) return true;
const ahead = frontier.get(item.seriesId);
if (!ahead) return true;
return !isAheadOf(ahead, item);
});
}
+7 -2
View File
@@ -1,8 +1,9 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -50,8 +51,12 @@ function createHomeStore() {
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
+43 -26
View File
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
it("loads a stored order", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
);
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"episodes",
"shows",
"movies",
"songs",
"albums",
"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");
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Default is shows, episodes, movies, songs, … — move movies up one.
searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([
const expected = [
"shows",
"movies",
"episodes",
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
"people",
];
expect(get(searchGroupOrder)).toEqual(expected);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
// Simulate a fresh app start reading the same storage.
vi.resetModules();
const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
});
it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Drag "albums" (index 4) to the front.
searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"shows",
"episodes",
"movies",
"songs",
"artists",
"people",
]);
});
it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1);
searchGroupOrder.move("movies", -1);
searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
});
+8 -2
View File
@@ -1,10 +1,11 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -81,7 +82,12 @@ function createTvStore() {
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
+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 {
setEnabled(enabled: boolean): void;
isSupported(): boolean;
}
declare global {
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
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
* 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 {
bridge()?.setEnabled(enabled);
b.setEnabled(enabled);
console.log("[BgAudio] setEnabled ->", enabled);
return true;
} catch (err) {
console.warn("[BgAudio] Failed to set enabled:", err);
return false;
}
}
+154 -26
View File
@@ -7,6 +7,8 @@ import {
normalizeGroupOrder,
reorderGroups,
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
scopeItemTypes,
type SearchGroupId,
} from "./searchScope";
@@ -92,9 +94,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -103,9 +107,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -113,34 +119,78 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
"songs",
"movies",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
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);
});
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", () => {
it("returns every group in saved order for the all scope", () => {
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
expect(
groupsForScope("all", [
"movies",
"songs",
"tvShows",
"shows",
"episodes",
"albums",
"artists",
]);
"people",
])
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
});
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("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", () => {
const groups = composeSearchGroups(results, "all", [
"tvShows",
"shows",
"episodes",
"movies",
"songs",
"albums",
"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", () => {
@@ -177,27 +243,44 @@ describe("composeSearchGroups", () => {
"albums",
]);
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);
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", () => {
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
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", () => {
// 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);
composeSearchGroups(results, "music", order);
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", () => {
@@ -205,7 +288,14 @@ describe("composeSearchGroups", () => {
"podcasts",
"movies",
] 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", () => {
@@ -219,7 +309,7 @@ describe("composeSearchGroups", () => {
});
describe("moveGroup", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves a group up", () => {
expect(moveGroup(order, "artists", -1)).toEqual([
@@ -227,7 +317,7 @@ describe("moveGroup", () => {
"artists",
"albums",
"movies",
"tvShows",
"shows",
]);
});
@@ -237,13 +327,13 @@ describe("moveGroup", () => {
"songs",
"artists",
"movies",
"tvShows",
"shows",
]);
});
it("is a no-op at the boundaries", () => {
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", () => {
@@ -258,18 +348,18 @@ describe("moveGroup", () => {
});
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", () => {
expect(reorderGroups(order, 0, 4)).toEqual([
"albums",
"artists",
"movies",
"tvShows",
"shows",
"songs",
]);
expect(reorderGroups(order, 4, 0)).toEqual([
"tvShows",
"shows",
"songs",
"albums",
"artists",
@@ -283,3 +373,41 @@ describe("reorderGroups", () => {
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";
}
/**
* 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
// ---------------------------------------------------------------------------
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[] = [
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"movies",
"tvShows",
"people",
];
export const GROUP_LABELS: Record<SearchGroupId, string> = {
shows: "TV Shows",
episodes: "Episodes",
movies: "Movies",
songs: "Songs",
albums: "Albums",
artists: "Artists",
movies: "Movies",
tvShows: "TV Shows",
people: "People",
};
/** 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",
albums: "music",
artists: "music",
movies: "movies",
tvShows: "tv",
people: null,
};
/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
shows: ["Series"],
episodes: ["Episode"],
movies: ["Movie"],
songs: ["Audio"],
albums: ["MusicAlbum"],
artists: ["MusicArtist"],
movies: ["Movie"],
tvShows: ["Series", "Episode"],
people: ["Person"],
};
export function groupItemTypes(group: SearchGroupId): string[] {
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.
*
@@ -123,13 +208,17 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
if (Array.isArray(stored)) {
for (const id of stored) {
if (typeof id !== "string" || !known.has(id)) continue;
const groupId = id as SearchGroupId;
if (typeof id !== "string") continue;
// 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;
seen.add(groupId);
order.push(groupId);
}
}
}
for (const id of DEFAULT_GROUP_ORDER) {
if (!seen.has(id)) order.push(id);
@@ -143,6 +232,8 @@ export function groupsForScope(
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): 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(
(id) => scope === "all" || GROUP_SCOPE[id] === scope
);
+20 -14
View File
@@ -6,8 +6,12 @@
import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import {
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
type SearchScope,
} from "$lib/utils/searchScope";
import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.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) {
if (query.trim()) {
await library.search(query, searchScope);
} else {
if (!query.trim()) {
library.clearSearch();
return;
}
}
async function handleScopeChange(next: SearchScope) {
searchScope = next;
if (searchQuery.trim()) {
await library.search(searchQuery, next);
if (shouldNavigateToSearch($page.url.pathname, query)) {
await goto(searchRouteUrl(query, searchScope));
// The query now lives in the URL; clear the header input so returning to
// a library page does not leave a stale term sitting in the box.
searchQuery = "";
}
}
</script>
@@ -74,14 +82,12 @@
<AppHeader search={librarySearch} />
{#snippet librarySearch()}
<!-- Scope chips live on /search, which owns the results. -->
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
{#if searchQuery.trim()}
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
{/if}
{/snippet}
<!-- 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)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
let searchResults = $derived($library.searchResults);
let searchQuery = $derived($library.searchQuery);
// Search results are rendered exclusively by /search — this page used to
// 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");
@@ -169,28 +170,7 @@
</script>
<div class="space-y-8">
{#if searchQuery}
<!-- 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}
{#if showInlineLibraryContent}
<!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6">
<div class="flex items-center gap-4">
+28
View File
@@ -147,6 +147,34 @@
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// Some series expose episodes directly as children rather than under
// season folders. In that case the season fetch above yields nothing —
// group the flat episode children by their season number so the Episode
// Focus View still has a populated `allEpisodes` (otherwise "More
// Episodes" collapses to just the current episode).
if (seasonData.every((s) => s.episodes.length === 0)) {
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
if (flatEpisodes.length > 0) {
const bySeason = new Map<number, MediaItem[]>();
for (const ep of flatEpisodes) {
const key = ep.parentIndexNumber ?? 1;
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
}
seasonData = [...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([seasonNumber, episodes]) => ({
// Synthesize a minimal season header from the episodes we have.
season: {
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
kind: "season",
indexNumber: seasonNumber,
name: `Season ${seasonNumber}`,
} as MediaItem,
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
}));
}
}
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
+14 -1
View File
@@ -20,6 +20,7 @@
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -536,7 +537,11 @@
function handleReportStop(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
// A skipped episode was already recorded as fully watched. Its unmount stop
// report arrives after the skip navigation carrying the mid-episode
// position; letting it through would undo that and restore the partial
// progress bar.
if (id && !shouldSuppressStopReport(id)) {
reportPlaybackStopped(id, positionSeconds);
}
// Intentionally do NOT emit a "stopped" player state here. This runs on both
@@ -592,6 +597,14 @@
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Skipping means "I'm done with this one" — record the outgoing episode as
// fully watched rather than leaving a mid-episode resume point behind. This
// also arms suppression of the VideoPlayer's unmount stop report, which
// would otherwise fire after navigation and overwrite the 100% progress
// with the partial position (see skipReporting.ts).
const skippedId = currentMedia?.id ?? itemId ?? null;
void reportSkippedEpisode(skippedId);
// Use replaceState so "close/back" returns to the library, not the previous episode.
// restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
+29 -3
View File
@@ -5,15 +5,41 @@
import Search from "$lib/components/Search.svelte";
import SearchResults from "$lib/components/search/SearchResults.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";
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
// a user who widened to All back to the route's scope on any navigation.
// 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) {
if (query.trim()) {