Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aaa80ff92 | ||
|
|
f7bcfe521d | ||
|
|
30dc3ba7f6 | ||
|
|
32f8de5c91 | ||
|
|
62873cab3d | ||
|
|
c55ff45692 | ||
|
|
58f2506966 | ||
|
|
a818fee297 | ||
|
|
a26a853f01 | ||
|
|
9d099268b9 | ||
|
|
e381d626c1 | ||
|
|
b12e99b7e1 | ||
|
|
dc8b732465 | ||
|
|
b98a530f48 | ||
|
|
b565c4ae6f | ||
|
|
79e10d7485 | ||
|
|
a2dbde5492 | ||
|
|
75cd07a5c0 | ||
|
|
64d07b8940 | ||
|
|
5b810f7fc3 | ||
|
|
1ae213ff39 | ||
|
|
98a6bca645 |
@@ -6,6 +6,69 @@ 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.4.0
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Favourites, across libraries.** A `/library/favorites` page renders
|
||||
favourites from every library with All / Movies / Shows / Music scope tabs,
|
||||
reusing the standard grid so card shape still follows the media — a mixed All
|
||||
tab reads as posters, squares and thumbnails side by side. Home carries
|
||||
favourite rows below Recently Added, and a row with no items does not render
|
||||
at all, so a fresh install shows no empty rows. Server favourite state is
|
||||
mirrored into the local database as results are cached, so offline browsing
|
||||
sees the same favourites as the server; a toggle made offline is never
|
||||
overwritten by a stale server value before it has been pushed.
|
||||
(UR-067, UR-069 → DR-113, DR-114, DR-115, DR-117, DR-118)
|
||||
|
||||
- **Search answers from the local index.** The instant leg read only downloaded
|
||||
items, so with no downloads it returned nothing and every keystroke fell
|
||||
through to a full `Recursive=true` server query. It now reads the whole synced
|
||||
catalog through the same availability CTE `get_items` uses, gated on the same
|
||||
`include_catalog_browse` flag, so search and browse cannot diverge. The index
|
||||
also gained MusicArtist, Playlist and People — the very groups search sorts
|
||||
results into. Re-indexing moved from a frontend startup call to a Rust
|
||||
background task with a 6h TTL, so a long session no longer searches a stale
|
||||
catalog. (UR-065 → DR-108, DR-110, DR-111)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Playback no longer restarts an episode at random on a flaky connection.**
|
||||
Background audio-only playback of a video streams a progressive mp3 transcode
|
||||
over plain HTTP, which is chunked and so declares no length: when the
|
||||
connection dropped mid-episode, ExoPlayer saw end-of-input and reported
|
||||
`STATE_ENDED`, indistinguishable from the real end. The app ran its
|
||||
end-of-episode logic mid-episode and playback parked in `STATE_ENDED`, where
|
||||
the next play intent from the lockscreen, notification or a Bluetooth
|
||||
reconnect seeks an ended player to position 0 — surfacing as "the episode
|
||||
randomly restarted". The item's runtime is now what decides: an end reported
|
||||
well short of it re-opens the stream where it stopped. (UR-040 → DR-129)
|
||||
|
||||
- **A network hiccup no longer kills playback outright.** Music and video
|
||||
declare a length, so a cut connection reaches them as an *error* rather than a
|
||||
phantom end — and every error stopped the player. A recoverable error now gets
|
||||
one bounded attempt at re-opening the stream where it stopped, with a growing
|
||||
backoff, leaving the rest of the queue intact. On Linux, MPV additionally
|
||||
reconnects inside the demuxer so ordinary blips never surface at all, and
|
||||
`EndFile(ERROR)` — previously a bare log line that left playback halted while
|
||||
the UI still showed "playing" — is now reported and recovered.
|
||||
(UR-004, UR-040 → DR-129, DR-130)
|
||||
|
||||
- **The player no longer reads 0:00 as a track ends on Linux.** MPV exposes
|
||||
`time-pos` and `duration` as properties of the *loaded* file, so at EOF it
|
||||
unloads and both stop resolving — reporting zero at exactly the moment
|
||||
end-of-file handling asks where playback reached. The last reading seen while
|
||||
media was loaded is now kept and used as the fallback. (UR-005 → DR-130)
|
||||
|
||||
- **Server-side deletions propagate to the local catalog.** `DELETE FROM items`
|
||||
existed nowhere, so items removed on the server lingered locally forever. A
|
||||
post-crawl mark-and-sweep now removes them, scoped to crawled types, skipping
|
||||
downloaded items, and refusing to run after a partial crawl. Separately,
|
||||
`items_fts` grew a full duplicate index on every catalog pass; it is now a
|
||||
real upsert, with a migration rebuilding existing indexes. (DR-110)
|
||||
|
||||
- **Android system bars and display cutout are handled correctly.** (UR-066)
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### ✨ Features
|
||||
|
||||
+103
-9
@@ -71,7 +71,17 @@ For a narrative overview of the system design, see
|
||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-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 |
|
||||
| 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. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); 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 |
|
||||
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
|
||||
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
|
||||
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
|
||||
| UR-065 | Search answers from a **locally indexed copy of the library**, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screen | High | Implemented |
|
||||
| UR-066 | The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on | High | Done |
|
||||
| UR-067 | Favourited media can be **found again**. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing reads | Medium | Done |
|
||||
| UR-068 | Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playing | Medium | Done |
|
||||
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
|
||||
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
|
||||
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,6 +122,9 @@ External system integrations and platform-specific implementations.
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
| IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented |
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -151,6 +164,8 @@ API endpoints and data contracts required for Jellyfin integration.
|
||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
||||
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
|
||||
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
||||
|
||||
### 2.3 Development Requirements
|
||||
|
||||
@@ -246,8 +261,44 @@ Internal architecture, components, and application logic.
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
| DR-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 |
|
||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 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 |
|
||||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||||
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
|
||||
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
|
||||
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
|
||||
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
||||
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
||||
| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done |
|
||||
| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done |
|
||||
| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection` → `EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done |
|
||||
| DR-103 | A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done |
|
||||
| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | Done |
|
||||
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
|
||||
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
|
||||
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
|
||||
| DR-108 | The instant (cache) leg of `repository_search` searches the **synced catalog**, not just downloads. `OfflineRepository::search` replaces its `downloaded_items` CTE with the `available_items` CTE `get_items` already uses — the same downloads branches plus a `synced_at IS NOT NULL` branch gated on the same `include_catalog_browse()` flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The `include_item_types` filter is switched from string interpolation to bound parameters, as `SearchOptions` is settable from the frontend and not only from `SearchScope` | Backend | UR-065 | Implemented |
|
||||
| DR-109 | Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and `last_catalog_sync` (already persisted to `app_settings`, previously read only for a UI hint) is older than `CATALOG_INDEX_TTL` (6 h); the `ConnectivityMonitor` reconnect signal re-evaluates the same condition immediately. An `AtomicBool` prevents concurrent passes, replacing `offlineCatalog.ts`'s `syncInProgress` — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. `RepositoryManager` gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case `catalog-index-event` | Backend | UR-065 | Implemented |
|
||||
| DR-110 | Index hygiene. `save_to_cache` switches from `INSERT OR REPLACE INTO items` to `ON CONFLICT(id) DO UPDATE`: REPLACE fires no `AFTER DELETE` trigger unless `recursive_triggers` is on (it is not — only `foreign_keys` and `journal_mode` are set), so `items_ad` never ran, and because `items.id` is a `TEXT PRIMARY KEY` each replacement also took a fresh rowid and appended a second `items_fts` entry — a duplicate index per sync, invisible in results but permanently degrading `MATCH`. The upsert preserves the rowid `items_fts` keys on and fires `items_au`; migration `021_rebuild_items_fts` clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because `items.parent_id` is `ON DELETE CASCADE` and a partial crawl would cascade away a whole series | Storage | UR-065 | Implemented |
|
||||
| DR-111 | The index covers what the result groups render: `CATALOG_ITEM_TYPES` gains `MusicArtist` and `Playlist`, and migration `022_people_fts` adds a `people_fts` virtual table over the existing `people` table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as `items_fts`. `OfflineRepository::search` UNIONs `people_fts` matches in as `Person` items when the resolved scope admits them — i.e. `SearchScope::All`, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server leg | Storage | UR-065, UR-060 | Implemented |
|
||||
| DR-112 | Safe-area insets come from **native**, not from `env()` alone. `env(safe-area-inset-*)` is 0px without `viewport-fit=cover` (missing from `app.html`, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the *display cutout* — never the status bar or navigation bar. Since `enableEdgeToEdge()` plus `targetSdk 36` make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. `WindowInsetsBridge` reads the real insets and publishes `jt-inset` custom properties; `app.css` folds them with `env()` via `max()` into `--safe-*`, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (`shellReservesBottomInset` hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's `fitsSystemWindows=true` — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed | UI | UR-066 | Done |
|
||||
| DR-113 | `MediaItem.user_data` is populated from the server instead of being hardcoded `None`. `JellyfinItem` gains a `UserData` field (`#[serde(alias = "UserData")]` → the existing `UserData` type) and `to_media_item` maps it, so every list and detail response carries favourite/played/resume state. `UserData` is named explicitly in the `Fields=` list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track `storageGetPlaybackProgress` fetch is the only way to colour one heart | Repository | UR-069 | Done |
|
||||
| DR-114 | Server favourite state is mirrored into the local `user_data` table by `OfflineRepository::save_to_cache` — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries `pending_sync = 0` and is guarded by `WHERE user_data.pending_sync = 0`, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed | Storage | UR-069 | Done |
|
||||
| DR-115 | Cross-library favourites query: a `get_favorites(scope, options)` repository method plus the `repository_get_favorites` command. Online issues `Filters=IsFavorite&Recursive=true` with `IncludeItemTypes` expanded from `SearchScope::item_types()` in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads `items ⨝ user_data (is_favorite = 1)` under the same `include_catalog_browse()` gate as browsing; hybrid races cache against server like `get_items` — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than `get_items` because favourites span libraries and `get_items` is `ParentId`-shaped | Repository | UR-067 | Done |
|
||||
| DR-116 | `GetItemsOptions.favorites_only` filters an existing library listing in place — online by appending `Filters=IsFavorite`, offline by joining `user_data` into the existing `available_items` CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already there | Repository | UR-067 | Done |
|
||||
| DR-117 | The Favourites page (`/library/favorites`) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing `LibraryViewTabs` + `LibraryGrid` + `MediaCard` so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a `SearchScope` value and nothing else. Reached from the library overview and from "See all" on the home rows | UI | UR-067 | Done |
|
||||
| DR-118 | Home carries favourite rows for movies, shows and music, loaded via `repository_get_favorites` per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rows | UI | UR-067 | Done |
|
||||
| DR-119 | `FavoriteButton` is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a `MediaCard` artwork overlay — and a `favorites` store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is `store override ?? item.userData?.isFavorite ?? false`. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cards | UI | UR-068 | Done |
|
||||
| DR-120 | Favourite toggles made while offline reach the server. A Rust drain, triggered by the `ConnectivityMonitor` offline→online transition, pushes every `user_data` row with `pending_sync = 1` and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case `favorites-changed` event (`{ itemIds }`) so open views update — without it a favourite marked on another client appears only on the *second* visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused `syncService.queueFavorite`, which is deleted rather than left as a second queue | Backend | UR-069 | Done |
|
||||
| DR-121 | Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one `get_video_download_url` already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates *within* HLS: returning `stream.mp4` is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves to | UI | UR-070 | Proposed |
|
||||
| DR-122 | The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Playback | UR-070 | Proposed |
|
||||
| DR-123 | The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default `original`, the direct static copy) over the Range-capable `/Videos/{id}/stream.mp4`, unaffected by bitrate changes and completing into an ordinary `downloads` row so offline browsing and `refresh_queue_local_sources` pick it up unchanged. Prerequisite: downloaded video is currently never played locally — `repository_get_video_stream_url` goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for video | Repository | UR-071 | In Progress |
|
||||
| DR-124 | Streamed bytes are kept only where they *are* the download artifact — a direct-play session. Android uses ExoPlayer `SimpleCache`/`CacheDataSource` keyed by item **and** media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv `stream-record`, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is **not** captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case instead | Playback | UR-071 | Proposed |
|
||||
| DR-125 | A capture is promoted to a completed `downloads` row only when it covers the whole resource; partials stay evictable cache. A new `downloads.source_rendition` column records the negotiated quality/container/codec (`NULL` for the existing paths, which are always `original`) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an **in-flight** capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Storage | UR-071 | Proposed |
|
||||
| DR-126 | Cache eviction only reclaims the *temporary* tier. `evict_lru_async` selected every completed download ordered by `completed_at ASC` with no `download_source` filter, so hitting the 10 GB storage limit deleted the **oldest** download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows; `COALESCE` rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting them | Storage | UR-071 | Done |
|
||||
| DR-127 | A cache entry *is* a download with a shorter life: same `downloads` row and same file handling, distinguished by `download_source = 'auto'` plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent (`'user'`) rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never move | Storage | UR-071 | Done |
|
||||
| DR-128 | Audio-only playback of *downloaded* media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video | Playback | UR-071 | Done |
|
||||
| DR-129 | A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) *Phantom end* — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the *absolute* position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) *Recoverable error* — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item **in place** (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping | Playback | UR-040, UR-004 | Done |
|
||||
| DR-130 | A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the *loaded* file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a *recoverable* error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded` → `player_on_playback_ended`). Android reports errors it has already declined as *unrecoverable*, so the echo never asks twice | Playback | UR-004, UR-040 | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
|
||||
---
|
||||
@@ -261,7 +312,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
@@ -297,7 +348,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
@@ -316,8 +367,18 @@ 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-060 | - | DR-090, DR-091, DR-111 |
|
||||
| UR-061 | - | DR-092 |
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||
| UR-066 | IR-031 | DR-112 |
|
||||
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| UR-070 | - | DR-121, DR-122 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128 |
|
||||
|
||||
---
|
||||
|
||||
@@ -408,10 +469,43 @@ 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 |
|
||||
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
|
||||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
|
||||
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
|
||||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
|
||||
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
|
||||
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done |
|
||||
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
||||
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
|
||||
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
|
||||
| UT-094 | `parseNativeInsets` accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting `NaNpx` (which would invalidate the whole padding declaration) | DR-112 | Done |
|
||||
| UT-095 | `safeAreaCssVars`/`applySafeAreaInsets` emit px-suffixed `jt-inset` custom properties for all four edges | DR-112 | Done |
|
||||
| UT-096 | `readNativeInsets` returns null with no bridge and survives a stale WebView proxy (missing or throwing `get`) instead of throwing out of layout init | IR-031, DR-112 | Done |
|
||||
| UT-097 | `initSafeArea` primes the document on start, re-applies on `jellytau-insets-changed` (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so `env()` still wins on iOS/desktop | IR-031, DR-112 | Done |
|
||||
| UT-098 | `shellReservesBottomInset` gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded | DR-112 | Done |
|
||||
| UT-099 | A Jellyfin item payload carrying `UserData.IsFavorite` maps to `MediaItem.user_data.is_favorite` | DR-113, JA-034 | Done |
|
||||
| UT-100 | `OnlineRepository::get_favorites` builds `Filters=IsFavorite` + `Recursive=true` + the scope's `IncludeItemTypes`, and omits the type filter entirely for `SearchScope::All` | DR-115, JA-033 | Done |
|
||||
| UT-101 | `OfflineRepository::get_favorites` returns only `is_favorite = 1` rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off | DR-115 | Done |
|
||||
| UT-102 | The `save_to_cache` favourite mirror does not overwrite a row with `pending_sync = 1` | DR-114 | Done |
|
||||
| UT-103 | The reconnect drain pushes pending favourites, clears `pending_sync`, and leaves failed rows pending | DR-120 | Done |
|
||||
| UT-104 | `get_items` with `favorites_only` filters online (endpoint) and offline (SQL) | DR-116 | Done |
|
||||
| UT-105 | `favorites` store precedence: override beats `userData.isFavorite` beats `false` | DR-119 | Done |
|
||||
| UT-106 | Un-favouriting removes an item from a favourites listing view | DR-117, DR-119 | Done |
|
||||
| UT-107 | The hybrid background refresh emits `favorites-changed` only for ids whose favourite state actually flipped | DR-120 | Done |
|
||||
| UT-109 | Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off | DR-108 | Done |
|
||||
| UT-110 | Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results | DR-108 | Done |
|
||||
| UT-111 | FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring | DR-108 | Done |
|
||||
| UT-112 | Repeated catalog passes leave one `items_fts` entry per item, not one per pass | DR-110 | Done |
|
||||
| UT-113 | The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server | DR-110 | Done |
|
||||
| UT-114 | Cached people are reachable from unscoped search and excluded from scoped search | DR-111 | Done |
|
||||
| UT-115 | Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not | DR-109 | Done |
|
||||
| UT-116 | `resolve_local_media_path` returns a completed download's file, and `None` for an in-progress download, a row whose file has been deleted, or an unknown item | DR-123 | Done |
|
||||
| UT-118 | `resolveVideoSource` prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path | DR-123 | Done |
|
||||
| UT-119 | The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync | DR-128 | Done |
|
||||
| UT-120 | Expiry reclaim takes only expired temporary entries: derived from `completed_at`+TTL, honouring an `expires_at` override, never a user download, and disabled by a zero TTL | DR-127 | Done |
|
||||
| UT-108 | LRU eviction reclaims only `'auto'` downloads and never a user's own, even when the user's is the oldest | DR-126 | Done |
|
||||
| UT-117 | A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded | DR-129 | Done |
|
||||
| UT-121 | An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's | DR-130 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Spec: Locally-indexed search
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-065 → DR-108, DR-109, DR-110, DR-111; IR-030
|
||||
**UX spec:** [ux-flows.md §6.1](../ux-flows.md) (search surface is unchanged)
|
||||
**Revises:** [scoped-search.md](scoped-search.md) and
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) — scope semantics are
|
||||
untouched; this changes only *which corpus* the cache leg searches.
|
||||
|
||||
## Summary
|
||||
|
||||
Search stops depending on a per-keystroke round trip to Jellyfin. The local
|
||||
SQLite catalog — which is already synced and already FTS5-indexed — becomes the
|
||||
corpus the instant leg of search reads, so results appear as fast as SQLite can
|
||||
answer, online or offline. A background indexer keeps that catalog fresh on a
|
||||
schedule instead of only at app start, prunes content deleted on the server, and
|
||||
covers the item types search groups results by. The server query stays, demoted
|
||||
to a background reconciliation that merges in late results for anything indexed
|
||||
since the last pass.
|
||||
|
||||
## Motivation
|
||||
|
||||
The pieces are already built and simply not wired together:
|
||||
|
||||
- [`sync_full_catalog`](../../src-tauri/src/commands/catalog.rs) already walks
|
||||
every library `Recursive=true` and persists items with `synced_at`.
|
||||
- `items_fts` (schema.rs migration 001) already indexes `name`, `overview`,
|
||||
`album_name`, `album_artist`, `artists`, `series_name` with keep-in-sync
|
||||
triggers.
|
||||
- `repository_search` is already two-phase — synchronous cache result, then a
|
||||
spawned server query merged in via the `search-event`.
|
||||
|
||||
What breaks the chain is that the cache leg is hard-restricted to *downloaded*
|
||||
items. `OfflineRepository::search` wraps its FTS query in a `downloaded_items`
|
||||
CTE requiring `d.status = 'completed'`:
|
||||
|
||||
```sql
|
||||
FROM items i
|
||||
JOIN items_fts fts ON fts.rowid = i.rowid
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ? AND items_fts MATCH ?
|
||||
```
|
||||
|
||||
So for a user with no downloads, phase 1 returns nothing on every query, and
|
||||
every debounced keystroke falls through to a full `Recursive=true` server
|
||||
request with `Limit=10000`. The populated local index is never read.
|
||||
|
||||
`get_items` does not have this problem — it gates a third `synced_at IS NOT NULL`
|
||||
branch on `include_catalog_browse()` (offline.rs, the "Show all server media"
|
||||
toggle). The asymmetry is the bug: **offline you can already browse the whole
|
||||
catalog but cannot search it.**
|
||||
|
||||
Three further defects found while confirming the above:
|
||||
|
||||
1. **The FTS index grows without bound.** `save_to_cache` uses
|
||||
`INSERT OR REPLACE INTO items`, but `recursive_triggers` is never enabled
|
||||
(`storage/mod.rs` sets only `foreign_keys` and `journal_mode`). SQLite fires
|
||||
`AFTER DELETE` triggers on a REPLACE *only* with recursive triggers on — so
|
||||
`items_ad` never runs, the old FTS row is orphaned, and because `items.id` is
|
||||
a `TEXT PRIMARY KEY` the replacement row takes a **new rowid** and inserts a
|
||||
second FTS entry. Every sync appends a duplicate index. Results stay correct
|
||||
(the `INNER JOIN … ON fts.rowid = i.rowid` hides orphans, and no rowid is ever
|
||||
reused because nothing is deleted) but `MATCH` degrades permanently.
|
||||
2. **Server-side deletions never propagate.** There is no `DELETE FROM items`
|
||||
anywhere in the codebase. The local catalog is append-only, so media removed
|
||||
from the server would stay searchable forever — tolerable when the cache was
|
||||
only a browse accelerator, not acceptable when it is the search corpus.
|
||||
3. **The index omits types search groups by.** `CATALOG_ITEM_TYPES` is
|
||||
`MusicAlbum, Movie, Series, Season, Episode, Audio, BoxSet` — no
|
||||
`MusicArtist`, no `Playlist`, and People live in a separate `people` table
|
||||
with no FTS at all. UR-060 mandates Artists and People result groups, so today
|
||||
those can *only* come from the server.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which corpus search reads (downloads-only vs full synced catalog) | **Rust** | Sync/availability policy over domain data. Changes if Jellyfin's API or the offline rules change, not if the UI is redesigned. Reuses the existing `include_catalog_browse()` flag so search and browse cannot diverge again. |
|
||||
| Index freshness policy — TTL, when a re-index is due, skip-while-offline | **Rust** | Explicitly named as domain policy in [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) ("reachability/sync policy"). It is currently frontend-driven in `offlineCatalog.ts`; this spec moves it. |
|
||||
| Which Jellyfin item types get indexed (`CATALOG_ITEM_TYPES`) | **Rust** | Textbook domain taxonomy — a category→item-type set. Must never appear in `src/`. |
|
||||
| Reconciling a crawl against local rows (what to prune) | **Rust** | Operates on domain data and depends on crawl completeness semantics. |
|
||||
| FTS query construction, ranking, scope→type expansion | **Rust** | Already there (`search_rank.rs`, `SearchScope::item_types()`); unchanged by this spec. |
|
||||
| Rendering a "catalog last indexed N ago" hint and any re-index button | **Frontend** | Pure presentation of a backend-supplied timestamp. |
|
||||
| Debounce interval, result group order, scope chips | **Frontend** | Input handling and view preference; changes only if the UI is redesigned. |
|
||||
|
||||
Borderline call, recorded: the **TTL value itself** (how many hours before a
|
||||
re-index is due) could be argued as a user preference and therefore frontend. It
|
||||
is placed in Rust because the frontend must not be able to decide *whether the
|
||||
cache is authoritative* — that is the same class of decision as
|
||||
`include_catalog_browse`, which already lives in Rust. If the TTL later becomes
|
||||
user-configurable it stays a Rust-owned setting the frontend edits through a
|
||||
command, not a frontend constant. Borderline defaults to Rust.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Search the full synced catalog (DR-108)
|
||||
|
||||
`OfflineRepository::search` mirrors `get_items` exactly: rename the CTE to
|
||||
`available_items` and add the same third branch, gated on the same flag.
|
||||
|
||||
```rust
|
||||
let catalog_branch = if include_catalog_browse() {
|
||||
"UNION
|
||||
|
||||
-- Synced catalog: fast online search, or the offline 'Show all
|
||||
-- server media' view. Mirrors get_items; see set_include_catalog_browse.
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
WHERE i.synced_at IS NOT NULL"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
```
|
||||
|
||||
No new IPC surface and no frontend change: `set_include_catalog_browse` is
|
||||
already called with `true` when online or when the offline toggle is on, and
|
||||
`false` only when offline with the toggle off. Search inherits the correct
|
||||
behaviour in all three states, and the "search is restricted to downloads" case
|
||||
survives for users who deliberately asked for downloads-only.
|
||||
|
||||
Also fix, in the same function, the `type_filter` built by **string
|
||||
interpolation** of `include_item_types` rather than bound parameters. It is
|
||||
currently safe only because callers pass `SearchScope`-derived values, but
|
||||
`SearchOptions.include_item_types` is settable directly from the frontend (as
|
||||
`GenericMediaListPage` does). Bind the values.
|
||||
|
||||
Phase 2 (the server query) is unchanged and still merges via `search-event`, so
|
||||
content added to the server since the last index still surfaces — just late
|
||||
rather than first.
|
||||
|
||||
### 2. Scheduled background indexer (DR-109, IR-030)
|
||||
|
||||
A Rust-owned task replaces the frontend's startup-only trigger.
|
||||
|
||||
```rust
|
||||
/// How long a full-catalog index stays fresh before a re-index is due.
|
||||
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- On app setup, spawn a tokio task that ticks every 30 min.
|
||||
- Each tick: if a repository is active **and** the server is reachable **and**
|
||||
`now - last_catalog_sync > CATALOG_INDEX_TTL`, run a full index pass.
|
||||
- On the existing `ConnectivityMonitor` reconnect signal, evaluate the same
|
||||
staleness condition immediately rather than waiting for the next tick.
|
||||
- Never run two passes concurrently (the existing `syncInProgress` guard moves
|
||||
into Rust as an `AtomicBool`).
|
||||
|
||||
`last_catalog_sync` is already written to `app_settings` by `sync_full_catalog`
|
||||
and is currently read only for a UI hint; this makes it load-bearing.
|
||||
|
||||
`RepositoryManager` (`commands/repository.rs`) is a `HashMap<String, …>` with no
|
||||
notion of an active handle, so the task has nothing to run against. Add:
|
||||
|
||||
```rust
|
||||
pub struct RepositoryManager {
|
||||
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
|
||||
active: Arc<Mutex<Option<String>>>, // set in create(), cleared in destroy()
|
||||
}
|
||||
```
|
||||
|
||||
Progress is reported with a **kebab-case** event (per the project convention):
|
||||
|
||||
```rust
|
||||
// event name: "catalog-index-event"
|
||||
#[derive(specta::Type, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogIndexEvent {
|
||||
pub state: CatalogIndexState, // #[serde(tag = "type")] Idle | Running | Complete | Failed
|
||||
pub libraries_done: usize,
|
||||
pub libraries_total: usize,
|
||||
pub items_indexed: usize,
|
||||
}
|
||||
```
|
||||
|
||||
`sync_full_catalog` stays a command so the UI can still force a pass; it and the
|
||||
scheduler share one internal `run_index_pass()`.
|
||||
|
||||
### 3. Index hygiene — no orphans, and deletions propagate (DR-110)
|
||||
|
||||
**Orphan growth.** Replace `INSERT OR REPLACE INTO items (…)` in `save_to_cache`
|
||||
with a true upsert:
|
||||
|
||||
```sql
|
||||
INSERT INTO items (id, server_id, …) VALUES (…)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, overview = excluded.overview, …,
|
||||
synced_at = excluded.synced_at
|
||||
```
|
||||
|
||||
This preserves the rowid (which `items_fts` keys on via `content_rowid`) and
|
||||
fires `items_au` instead of silently orphaning a row. Preferred over
|
||||
`PRAGMA recursive_triggers = ON` because it also stops the rowid churn, and the
|
||||
three FTS triggers are the only triggers in the schema so nothing else depends
|
||||
on REPLACE semantics.
|
||||
|
||||
A new migration `021_rebuild_items_fts` clears the orphans already accumulated on
|
||||
existing installs:
|
||||
|
||||
```sql
|
||||
INSERT INTO items_fts(items_fts) VALUES('rebuild');
|
||||
```
|
||||
|
||||
**Deletions.** After a library crawls *successfully and completely*, reconcile:
|
||||
delete local rows for that library whose `id` was not seen in the crawl. Two
|
||||
constraints the implementation must respect:
|
||||
|
||||
- Skip any item with a completed download — the user has the file; removing the
|
||||
row would orphan it. Prune only synced-but-not-downloaded rows.
|
||||
- Only sweep libraries whose crawl succeeded. `sync_full_catalog` is
|
||||
deliberately best-effort per library, and `items.parent_id` is
|
||||
`ON DELETE CASCADE` — sweeping on a partial crawl would cascade a whole series
|
||||
away because one request timed out.
|
||||
|
||||
### 4. Index the types search groups by (DR-111)
|
||||
|
||||
Add `MusicArtist` and `Playlist` to `CATALOG_ITEM_TYPES`.
|
||||
|
||||
People need a different mechanism: they live in `people` (`id`, `server_id`,
|
||||
`name`, `overview`, `primary_image_tag`, `synced_at`), populated incidentally by
|
||||
item-detail fetches, with no FTS table. Migration `022_people_fts` adds one
|
||||
mirroring the `items_fts` pattern:
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
|
||||
name, overview, content='people', content_rowid='rowid'
|
||||
);
|
||||
-- plus people_ai / people_ad / people_au triggers
|
||||
```
|
||||
|
||||
`OfflineRepository::search` UNIONs `people_fts` matches into its result set as
|
||||
`Person`-typed items when the resolved scope permits them (i.e. when
|
||||
`include_item_types` is `None` — `SearchScope::All`). `search_rank.rs` already
|
||||
handles `MediaKind::Person`, so ranking needs no change.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Incremental indexing** (e.g. Jellyfin's `MinDateLastSaved`). A full crawl is
|
||||
what makes the deletion sweep in §3 sound — it yields the authoritative id set
|
||||
per library. An incremental pass cannot detect deletions, so it would need a
|
||||
separate reconciliation strategy. Worth revisiting if full crawls prove too
|
||||
slow on large libraries; measure first.
|
||||
- **Changing search UX** — scope chips, group order, the debounce, and the
|
||||
`/search` route are untouched.
|
||||
- **Removing the server leg.** Phase 2 stays.
|
||||
- The two dead search implementations (`storage_search_items` in
|
||||
`commands/storage/mod.rs`, `offline_search` in `commands/offline.rs`) — both
|
||||
registered in `lib.rs` and exported to `bindings.ts`, neither called from the
|
||||
frontend. Deleting them is correct but is cleanup, not this feature; file
|
||||
separately so this spec's diff stays reviewable.
|
||||
- `GenericMediaListPage` passing raw `includeItemTypes` and re-implementing the
|
||||
store's request-id/event protocol. A real boundary smell, tracked separately.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] With a synced catalog and **zero downloads**, typing a query returns
|
||||
results from the local index before any server request completes.
|
||||
- [ ] Offline with "Show all server media" **on**, search returns the full
|
||||
catalog (non-downloaded entries greyed out, matching browse).
|
||||
- [ ] Offline with the toggle **off**, search returns downloaded media only —
|
||||
the behaviour that exists today.
|
||||
- [ ] Re-running a full index pass N times does not grow `items_fts` row count
|
||||
beyond the `items` row count.
|
||||
- [ ] An item deleted server-side disappears from local search after one index
|
||||
pass; a **downloaded** item deleted server-side does not.
|
||||
- [ ] A library that fails mid-crawl prunes nothing.
|
||||
- [ ] Searching an artist or actor name returns results with the server
|
||||
unreachable.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes.
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated (new `CatalogIndexEvent` type).
|
||||
|
||||
## Testing
|
||||
|
||||
Per CLAUDE.md, each defect gets a **failing test first**.
|
||||
|
||||
Rust (`cargo test`), against an in-memory DB seeded with synced-but-not-
|
||||
downloaded items:
|
||||
|
||||
- `search` returns synced items when `include_catalog_browse()` is true, and
|
||||
only downloaded items when false. *Fails today* — the current CTE returns
|
||||
empty in the first case.
|
||||
- Upserting the same item twice leaves exactly one `items_fts` row. *Fails
|
||||
today.*
|
||||
- The sweep removes a vanished synced item, retains a vanished downloaded item,
|
||||
and no-ops for a library whose crawl errored.
|
||||
- `type_filter` binds parameters — a type string containing a quote does not
|
||||
alter the query.
|
||||
- Staleness: a `last_catalog_sync` inside the TTL does not trigger a pass; one
|
||||
outside it does; offline never does.
|
||||
- `people_fts` matches surface as `Person` items under `SearchScope::All` and
|
||||
are excluded under `Music`/`Movies`/`Tv`.
|
||||
|
||||
Frontend (`vitest`): the catalog-index event maps to the staleness hint; no
|
||||
change to the search store's request-id/stale-response handling, which stays
|
||||
covered by its existing tests.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| `OfflineRepository::search` availability CTE | `// TRACES: UR-065 \| DR-108` |
|
||||
| Background indexer task + scheduling | `// TRACES: UR-065 \| DR-109, IR-030` |
|
||||
| `save_to_cache` upsert + FTS rebuild migration | `// TRACES: UR-065 \| DR-110` |
|
||||
| Deletion reconciliation | `// TRACES: UR-065 \| DR-110` |
|
||||
| `CATALOG_ITEM_TYPES` widening + `people_fts` | `// TRACES: UR-065, UR-060 \| DR-111` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **A parallel Claude session may be active in this repo.** Run `git diff`
|
||||
before "repairing" changes you did not make (CLAUDE.md gotchas).
|
||||
- The frontend's `offlineCatalog.ts` startup trigger should be **removed**, not
|
||||
left alongside the Rust scheduler — two independent triggers with one
|
||||
`syncInProgress` guard each is how double-crawls happen.
|
||||
- `downloads` has a relaxed FK to `items` (migration 005). Verify the deletion
|
||||
sweep's interaction with it before enabling the sweep, and check whether
|
||||
`parent_id`'s `ON DELETE CASCADE` reaches further than intended.
|
||||
- The existing 100 ms `cache_with_timeout` in `hybrid.rs` returns *empty* on
|
||||
timeout rather than erroring. Once the cache leg is the primary path, that
|
||||
budget may need raising — an FTS query over a large catalog on cold page cache
|
||||
can exceed it, and the failure mode is a silently empty result.
|
||||
- Keep `SearchScope` semantics as-is: `All => None` (no filter), deliberately
|
||||
not a union, so People and folders are not filtered out (DR-063).
|
||||
- Noted but deliberately not fixed here: `pushCatalogVisibility` in
|
||||
`offlineCatalog.ts` derives the flag as `connected || showCatalog` — the
|
||||
frontend computing an availability *policy*, even though the flag itself is
|
||||
Rust-stored. DR-108 depends on that derivation being correct and it is, so
|
||||
this spec leaves it alone. Once DR-109 has moved sync policy into Rust, the
|
||||
derivation belongs there too, with the frontend pushing only the raw user
|
||||
toggle. Folding it into this change would enlarge the diff for no behavioural
|
||||
gain — but do not add *new* policy on the frontend side of that line.
|
||||
@@ -0,0 +1,403 @@
|
||||
# Spec: Favourites — marking, browsing, and sync
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-067, UR-068, UR-069 → DR-113 … DR-120; JA-033, JA-034
|
||||
(allocated in [requirements.md](../requirements.md); tests UT-099 … UT-107).
|
||||
Note: UR-066/DR-112/IR-031 were claimed by the concurrent safe-area work while
|
||||
this spec was being written, so the ids here start one higher than first drafted.
|
||||
Existing: UR-017 → DR-021, JA-017, JA-018 (the toggle itself, already built).
|
||||
**UX spec:** [ux-flows.md](../ux-flows.md) §3.2 (full-player favourite), §5.2
|
||||
(album detail favourite), §5B.3 (movie detail hero: *Play / Download / Favorite*)
|
||||
— all three already specify favourite affordances that **do not exist in the
|
||||
build**. This spec closes those, and adds a new §5C for the Favourites browse
|
||||
surface.
|
||||
**Supersedes / revises:** nothing.
|
||||
|
||||
## Summary
|
||||
|
||||
JellyTau can favourite an item but can never show you what you favourited. The
|
||||
heart is mounted in exactly one place (the mini player), no query anywhere asks
|
||||
Jellyfin or the local database for favourites, and favourites marked on any other
|
||||
client are invisible here. This spec adds the read side (a Favourites page, home
|
||||
carousels, an in-library filter), puts the heart on detail pages and media cards,
|
||||
teaches the backend to ingest server-side favourite state, and drains favourite
|
||||
toggles made while offline.
|
||||
|
||||
## Background: what exists today
|
||||
|
||||
Verified in code, 2026-08-04. The **write** path is real and mostly correct; the
|
||||
**read** path does not exist at all.
|
||||
|
||||
1. **Toggling works, from one place only.**
|
||||
[FavoriteButton.svelte](../../src/lib/components/FavoriteButton.svelte) is
|
||||
mounted solely in
|
||||
[MiniPlayer.svelte:381](../../src/lib/components/player/MiniPlayer.svelte#L381).
|
||||
Nothing else in `src/` renders it — so the only favouritable item in the app
|
||||
is the one currently playing.
|
||||
|
||||
2. **The toggle's plumbing is sound.**
|
||||
[favorites.ts](../../src/lib/services/favorites.ts) writes local first
|
||||
(`storage_toggle_favorite`,
|
||||
[storage/mod.rs:908](../../src-tauri/src/commands/storage/mod.rs#L908) — sets
|
||||
`user_data.is_favorite` + `pending_sync = 1`), then POST/DELETEs
|
||||
`/Users/{uid}/FavoriteItems/{id}`
|
||||
([online.rs:1652](../../src-tauri/src/repository/online.rs#L1652)) only when
|
||||
connected. Leave this design intact.
|
||||
|
||||
3. **`MediaItem.user_data` is always `None` from the server.**
|
||||
`JellyfinItem` has no `UserData` field, and `to_media_item` hardcodes
|
||||
[`user_data: None`](../../src-tauri/src/repository/online.rs#L656) with the
|
||||
comment *"User data not included in basic item responses"*. The only
|
||||
populated `user_data` in the app comes from
|
||||
[series_progress.rs](../../src-tauri/src/repository/series_progress.rs#L244)
|
||||
and the local read in
|
||||
[offline.rs:115](../../src-tauri/src/repository/offline.rs#L115). **Nothing
|
||||
ingests server favourite state**, which is why the mini player has to fetch
|
||||
`storageGetPlaybackProgress` per track to colour one heart
|
||||
([MiniPlayer.svelte:75-93](../../src/lib/components/player/MiniPlayer.svelte#L75-L93)).
|
||||
|
||||
4. **No favourites query exists.**
|
||||
[`GetItemsOptions`](../../src-tauri/src/repository/types.rs#L278) has no
|
||||
favourites field; `Filters=IsFavorite` appears nowhere; no SQL selects
|
||||
`is_favorite = 1`; there is no `/library/favorites` route and no favourites
|
||||
carousel in [home.ts](../../src/lib/stores/home.ts).
|
||||
|
||||
5. **Offline favourites are silently lossy.** Offline `mark_favorite` /
|
||||
`unmark_favorite` are no-ops
|
||||
([offline.rs:1620](../../src-tauri/src/repository/offline.rs#L1620)), so an
|
||||
offline toggle survives only as a local row with `pending_sync = 1` — and
|
||||
**nothing ever drains that flag**. `syncService.queueFavorite`
|
||||
([syncService.ts:91](../../src/lib/services/syncService.ts#L91)) exists with
|
||||
no callers.
|
||||
|
||||
## Motivation
|
||||
|
||||
Favouriting is a promise: the app takes the input and shows a "Added to
|
||||
favorites" toast, then discards it as far as the user can tell. Three of the UX
|
||||
flows already specify favourite buttons that were never built, and the one that
|
||||
was built (mini player) writes to a store nothing reads. Either the feature gets
|
||||
its read side or the heart should be removed — this spec takes the first option.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Favourites **scope** → set of Jellyfin item types | Rust | Domain taxonomy. Changes when Jellyfin adds/renames a type, never when the UI is redesigned. Reuses the canonical `SearchScope::item_types()` ([types.rs:324](../../src-tauri/src/repository/types.rs#L324)) — the exact leak class of [scoped-search-boundary.md](scoped-search-boundary.md). |
|
||||
| Cross-library favourites query (`Filters=IsFavorite`, `Recursive`, paging, sort field) | Rust | Query shaping against the Jellyfin API is domain logic; the endpoint's contract changes with the server, not the UI. |
|
||||
| Offline favourites SQL (join `user_data`, downloaded/catalog gating) | Rust | Storage + domain. Must obey the existing catalog-browse gate (DR-080) which the frontend cannot see. |
|
||||
| Deserialising Jellyfin `UserData` into `MediaItem.user_data` | Rust | Provider payload mapping. |
|
||||
| Mirroring server favourite state into the local `user_data` table | Rust | Cache/sync policy. |
|
||||
| Conflict rule: a local row with `pending_sync = 1` beats the server value | Rust | Business rule about which write wins; nothing to do with rendering. |
|
||||
| Draining pending favourite toggles on reconnect | Rust | Sync policy, and it must run whether or not any view is mounted — a frontend-driven drain dies with the component. Consistent with *reachability from real traffic* (DR-055). |
|
||||
| Which surfaces show favourites, tab order, row placement on home | Frontend | Pure presentation; changes only if the UI is redesigned. |
|
||||
| Heart placement, animation, toast, haptics, empty-state copy | Frontend | Presentation. |
|
||||
| In-session optimistic heart state shared across views | Frontend | View state, not persisted truth; the durable write already goes to Rust. |
|
||||
|
||||
**Borderline, and the tie-breaker used:** *which* scopes appear as tabs (All /
|
||||
Movies / Shows / Music) is a presentation choice — the frontend picks which
|
||||
`SearchScope` values to offer. What each scope *means* is Rust's. The frontend
|
||||
sends the enum value and never names an item type in connection with favourites.
|
||||
Single-type pages (`itemType: "Movie"` on the Movies list page) stay as they are;
|
||||
this rule targets category taxonomy, not every mention of a type.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Server user data reaches `MediaItem` (Rust, DR-113, JA-034)
|
||||
|
||||
Jellyfin returns `UserData` on `/Users/{uid}/Items*` responses. Add the field to
|
||||
`JellyfinItem` and map it in `to_media_item`, replacing the hardcoded `None`:
|
||||
|
||||
```rust
|
||||
// in JellyfinItem
|
||||
#[serde(alias = "UserData")]
|
||||
pub user_data: Option<JellyfinUserData>,
|
||||
```
|
||||
|
||||
`JellyfinUserData` deserialises `IsFavorite`, `Played`, `PlaybackPositionTicks`,
|
||||
`PlayCount`, `LastPlayedDate` into the existing
|
||||
[`UserData`](../../src-tauri/src/repository/types.rs#L44) type (which already
|
||||
carries `is_favorite` and already serialises camelCase, so `bindings.ts` needs no
|
||||
new type — only regeneration). Add `UserData` to the `Fields=` list in `get_items`
|
||||
/ `get_item` so the shape is explicit rather than relying on the default.
|
||||
|
||||
Wire shape, unchanged from today's `UserData`:
|
||||
|
||||
```ts
|
||||
item.userData?.isFavorite // boolean | null | undefined
|
||||
```
|
||||
|
||||
Delete the now-false `// User data not included in basic item responses` comment.
|
||||
|
||||
### 2. Local mirror of server favourites (Rust, DR-114)
|
||||
|
||||
Choke point: `save_to_cache(parent_id, &items)` in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — every server result
|
||||
that gets cached (including via
|
||||
[`cache_items_from_server`](../../src-tauri/src/repository/hybrid.rs#L124) and
|
||||
the background cache refresh) passes through it.
|
||||
|
||||
For each item carrying `user_data.is_favorite`, upsert:
|
||||
|
||||
```sql
|
||||
INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_favorite = excluded.is_favorite,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE user_data.pending_sync = 0; -- local unsynced change wins
|
||||
```
|
||||
|
||||
The `WHERE` on the conflict clause is the whole conflict rule: a toggle made
|
||||
offline is never overwritten by a stale server value before it has been pushed.
|
||||
|
||||
### 3. Favourites queries (Rust, DR-115, DR-116, JA-033)
|
||||
|
||||
**(a) In-library filter** — one new field on `GetItemsOptions`:
|
||||
|
||||
```rust
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub favorites_only: Option<bool>,
|
||||
```
|
||||
|
||||
- online `get_items`: append `&Filters=IsFavorite` when true.
|
||||
- offline `get_items`: add `INNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1`, composed with the existing `available_items` CTE so the downloads-only gate still applies.
|
||||
|
||||
Frontend sends `{ favoritesOnly: true }` (camelCase — nested struct field, needs
|
||||
the existing `#[serde(rename_all = "camelCase")]` on `GetItemsOptions`, already
|
||||
present).
|
||||
|
||||
**(b) Cross-library favourites** — a new trait method, because favourites span
|
||||
libraries and `get_items` is `ParentId`-shaped:
|
||||
|
||||
```rust
|
||||
/// TRACES: UR-067 | DR-115 | JA-033
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
```
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_favorites(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String>
|
||||
```
|
||||
|
||||
Frontend call (command name matches the Rust fn exactly; top-level params
|
||||
auto-camelCase; `SearchScope` is `#[serde(rename_all = "camelCase")]` so the wire
|
||||
values are `"all" | "music" | "movies" | "tv"`):
|
||||
|
||||
```ts
|
||||
await commands.repositoryGetFavorites(handle, "movies", { limit: 100 });
|
||||
```
|
||||
|
||||
- **online**: `/Users/{uid}/Items?Filters=IsFavorite&Recursive=true&SortBy=SortName&SortOrder=Ascending` + `&IncludeItemTypes=…` from `scope.item_types()` (omit entirely on `None`, per that function's contract) + the standard `Fields=`.
|
||||
- **offline**: `items ⨝ user_data (is_favorite = 1)`, type filter from the same `scope.item_types()`, honouring `include_catalog_browse()`.
|
||||
- **hybrid**: same cache-first race as `get_items`, **including the DR-080 rule** — with the catalog-browse gate off, an empty offline result is authoritative and must not fall through to the server. Getting this wrong reproduces Defect B from [offline-downloaded-only-filter.md](offline-downloaded-only-filter.md).
|
||||
|
||||
**The cache-first result arrives stale, and there is no second payload.** On a
|
||||
cache hit, `hybrid::get_items` returns the local rows and refreshes the cache in
|
||||
a background task whose result the frontend never sees — fine for a library
|
||||
listing that changes daily, wrong for favourites, where the *point* is that
|
||||
another client just changed something. Favourites is the second read path (after
|
||||
search) that needs the deferred update, so the background refresh in
|
||||
`get_favorites` must emit the same `favorites-changed` event as §4 when the
|
||||
server's favourite set differs from what was returned:
|
||||
|
||||
```
|
||||
favorites-changed → { itemIds: string[] } // union of ids whose is_favorite flipped
|
||||
```
|
||||
|
||||
Both producers (background refresh, reconnect drain) emit the identical payload,
|
||||
and the frontend has one handler that refreshes the `favorites` store. Without
|
||||
this, a favourite marked on another client appears in JellyTau only on the
|
||||
*second* visit to the page.
|
||||
|
||||
### 4. Draining offline toggles (Rust, DR-120)
|
||||
|
||||
On the offline→online transition already detected by `ConnectivityMonitor`,
|
||||
select `user_data WHERE pending_sync = 1 AND is_favorite IS NOT NULL`, POST or
|
||||
DELETE `/Users/{uid}/FavoriteItems/{id}` per row, then set `pending_sync = 0` and
|
||||
`synced_at`. Failures leave the row pending for the next transition.
|
||||
|
||||
Emit a kebab-case event when anything changed, so open views refresh without
|
||||
polling:
|
||||
|
||||
```
|
||||
favorites-changed → { itemIds: string[] }
|
||||
```
|
||||
|
||||
`syncService.queueFavorite` is dead code once this lands — delete it or point it
|
||||
at the backend drain; do not leave two competing queues.
|
||||
|
||||
### 5. Frontend surfaces (DR-117, DR-118, DR-119)
|
||||
|
||||
**Favourites page** — new route `/library/favorites`:
|
||||
- Scope tabs *All / Movies / Shows / Music* via the existing `LibraryViewTabs`; each tab sends a `SearchScope` value, nothing more.
|
||||
- Renders through `LibraryGrid` + `MediaCard` (tracklist for Music→tracks if the tab is later split; not in this pass).
|
||||
- Entry points: a card on the library overview ([library/+page.svelte](../../src/routes/library/+page.svelte)) and "See all" on the home rows.
|
||||
- Empty state per tab: "Nothing favourited yet — tap the heart on anything you like."
|
||||
|
||||
**Home carousels** — `favoriteMovies`, `favoriteShows`, `favoriteMusic` added to
|
||||
[home.ts](../../src/lib/stores/home.ts), each `repositoryGetFavorites(scope, { limit: 20 })`,
|
||||
rendered after *Recently Added* and **only when non-empty** (no empty rows on a
|
||||
fresh install).
|
||||
|
||||
**In-library filter** — a favourites toggle in the header of
|
||||
[GenericMediaListPage](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
and the Movies/TV landing pages, passing `favoritesOnly: true` into the existing
|
||||
`repo.getItems(...)` options. Session-scoped state; not persisted (a persisted
|
||||
filter that hides most of a library is a support call waiting to happen).
|
||||
|
||||
**Hearts** — mount `FavoriteButton`:
|
||||
- Movie / series detail hero button row, beside the download buttons ([library/[id]/+page.svelte:528-553](../../src/routes/library/%5Bid%5D/+page.svelte#L528-L553)) — closes ux-flows §5B.3.
|
||||
- `EpisodeFocusView`, `ArtistDetailView`, `PlaylistDetailView`, album detail — closes ux-flows §5.2.
|
||||
- `MediaCard` artwork overlay (top-right). Suppressed on `isServerOnly` cards, and must not fight the existing long-press/scroll-guard handlers ([MediaCard.svelte:60-70](../../src/lib/components/library/MediaCard.svelte#L60-L70)) — the heart is its own button and stops propagation.
|
||||
|
||||
**Shared optimistic state** — a small `favorites` store (`Map<string, boolean>`
|
||||
overlay + `favorites.set(id, value)`), so un-hearting an item on the Favourites
|
||||
page removes it from the grid and from any home row without a refetch, and a
|
||||
heart tapped on a card is reflected on the detail page. Resolution order:
|
||||
|
||||
```
|
||||
favorites store override ?? item.userData?.isFavorite ?? false
|
||||
```
|
||||
|
||||
`toggleFavorite()` updates the store alongside its existing local + server
|
||||
writes; the `favorites-changed` event refreshes it. This removes the mini
|
||||
player's per-track `storageGetPlaybackProgress` fetch once items carry
|
||||
`userData`.
|
||||
|
||||
### 6. Offline behaviour
|
||||
|
||||
Toggling offline keeps working exactly as now (local write + `pending_sync`), and
|
||||
now actually reaches the server on reconnect (§4). The Favourites page offline
|
||||
shows favourites among downloaded/cached items, subject to the existing
|
||||
catalog-browse gate. The offline repo's no-op `mark_favorite`/`unmark_favorite`
|
||||
stay no-ops — the local write plus the drain is the offline path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Favouriting people, genres, or collections; favourite **playlists** are included only insofar as they fall under the Music scope.
|
||||
- Sorting by "date favourited" — Jellyfin does not expose it. Favourites sort by name.
|
||||
- A dedicated bottom-nav tab for favourites (reachable from library overview + home).
|
||||
- Building a playlist or download batch from favourites.
|
||||
- Reconciling favourites for items that no longer exist on the server.
|
||||
- Splitting the Music tab into albums/artists/tracks sub-tabs.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Favouriting is possible from movie, series, episode, album, artist and playlist detail pages, and from media cards in any grid.
|
||||
- [ ] A favourite marked in another Jellyfin client shows a filled heart in JellyTau without toggling it here.
|
||||
- [ ] `/library/favorites` lists favourites across libraries, filtered by the All/Movies/Shows/Music tabs.
|
||||
- [ ] Home shows favourite rows for movies, shows and music, and shows no row when a category has none.
|
||||
- [ ] Movies/TV/Music list pages can be filtered to favourites only.
|
||||
- [ ] Un-hearting an item on one surface updates the others without a manual refresh.
|
||||
- [ ] A favourite toggled while offline reaches the server after reconnect (verified against a real server or a fake repository).
|
||||
- [ ] Offline, the Favourites page respects the "Show all server media" gate — with it off, an empty result stays empty and does not fall through to the server.
|
||||
- [ ] No item-type set appears in `src/` in connection with favourites; the frontend sends `SearchScope` only.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes (necessary, not sufficient — see CLAUDE.md).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
||||
|
||||
## Testing
|
||||
|
||||
**🔴 §4 (the pending-sync drain) is a bug fix — failing test first.** Write a
|
||||
test that toggles a favourite with the repository offline, transitions to online,
|
||||
and asserts the server call happened; watch it fail before writing the drain.
|
||||
|
||||
Rust (`cd src-tauri && cargo test`):
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| UT-099 | A Jellyfin item JSON fixture with `UserData.IsFavorite: true` maps to `MediaItem.user_data.is_favorite == Some(true)` |
|
||||
| UT-100 | `online::get_favorites` builds an endpoint with `Filters=IsFavorite`, `Recursive=true`, and the scope's `IncludeItemTypes`; `SearchScope::All` omits the type filter entirely |
|
||||
| UT-101 | `offline::get_favorites` returns only `is_favorite = 1` rows, respects the scope type filter, and returns nothing extra when the catalog-browse gate is off |
|
||||
| UT-102 | `save_to_cache` mirror does **not** overwrite a row with `pending_sync = 1` |
|
||||
| UT-103 | Drain pushes pending rows, clears `pending_sync`, sets `synced_at`, and leaves failed rows pending |
|
||||
| UT-104 | `get_items` with `favorites_only: true` filters both online (endpoint) and offline (SQL) |
|
||||
| UT-107 | The background refresh in `hybrid::get_favorites` emits `favorites-changed` with the flipped ids, and emits nothing when the server set matches the cache |
|
||||
|
||||
Frontend (`bun run test`):
|
||||
|
||||
| Test | Covers |
|
||||
|------|--------|
|
||||
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
||||
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
|
||||
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. |
|
||||
|
||||
Any component logic worth testing gets extracted into a plain `.ts` module first
|
||||
(`favoritesView.ts`), rather than tested through the component.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|-------|-----|
|
||||
| `JellyfinUserData` + `to_media_item` mapping | `// TRACES: UR-069 \| DR-113, JA-034 \| UT-099` |
|
||||
| `save_to_cache` user_data mirror | `// TRACES: UR-069 \| DR-114 \| UT-102` |
|
||||
| `get_favorites` (trait, online, offline, hybrid) + command | `// TRACES: UR-067 \| DR-115, JA-033 \| UT-100, UT-101` |
|
||||
| `GetItemsOptions.favorites_only` handling | `// TRACES: UR-067 \| DR-116 \| UT-104` |
|
||||
| `/library/favorites` route + tabs | `// TRACES: UR-067 \| DR-117` |
|
||||
| Home favourite carousels | `// TRACES: UR-067 \| DR-118` |
|
||||
| `FavoriteButton` mounts + `favorites` store | `// TRACES: UR-068 \| DR-119 \| UT-105, UT-106` |
|
||||
| Pending-favourite drain + `favorites-changed` | `// TRACES: UR-069 \| DR-120 \| UT-103` |
|
||||
|
||||
New requirement rows to add to [requirements.md](../requirements.md):
|
||||
|
||||
- **UR-067** — Browse favourited media across libraries (page, home rows, in-library filter).
|
||||
- **UR-068** — Mark/unmark favourites from browse and detail surfaces, not only the player.
|
||||
- **UR-069** — Favourite state stays consistent with the server in both directions.
|
||||
- **DR-113 … DR-120** — as tabled above.
|
||||
- **JA-033** — Query favourite items (`Filters=IsFavorite`).
|
||||
- **JA-034** — Read `UserData` from item responses.
|
||||
|
||||
## Implementation notes (as built)
|
||||
|
||||
Two things landed differently from the design above, both forced by where the
|
||||
`AppHandle` lives:
|
||||
|
||||
1. **The `favorites-changed` event is emitted from the command layer, not the
|
||||
repository.** `HybridRepository` has no `AppHandle` — the same reason
|
||||
`search-event` is emitted from `repository_search`. `repository_get_favorites`
|
||||
therefore does the two-phase read itself (cache leg returned, server leg
|
||||
spawned) and diffs the two id sets via `changed_favorite_ids`, which is
|
||||
extracted and unit-tested (UT-107) rather than buried in the spawn.
|
||||
2. **The drain hooks the existing `connectivity:reconnected` event** via
|
||||
`app.listen` in `commands/favorites.rs`, rather than reaching into
|
||||
`ConnectivityMonitor` (which knows nothing about repositories). It drains
|
||||
through a narrow `FavoriteSink` trait so it can be tested against a recording
|
||||
double instead of a forty-method `MediaRepository` mock.
|
||||
|
||||
3. **The command falls back to `HybridRepository::get_favorites` when nothing is
|
||||
cached.** The two-phase read alone paints "Nothing favourited yet" on a fresh
|
||||
install and corrects it a server round trip later, which is a wrong answer
|
||||
shown to the user. An empty cache leg therefore defers to the repository's
|
||||
own cache-first-then-server read. That read was also fixed to *save through*
|
||||
on a server hit — without it the page re-queried the server on every visit
|
||||
and the DR-114 mirror was never filled by this path.
|
||||
|
||||
Also as built: `DatabaseService` is not object-safe (generic methods), so the
|
||||
drain takes `Arc<RusqliteService>` like the rest of the storage code, and
|
||||
`get_items`' endpoint construction was extracted to `build_get_items_endpoint`
|
||||
so the `favorites_only` filter could be asserted without an HTTP server.
|
||||
|
||||
**Not built:** the full-player heart. ux-flows §3.2 lists one among the full
|
||||
player's secondary controls and it remains unbuilt — recorded as a known
|
||||
deviation in ux-flows §5C.5 rather than silently dropped.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.
|
||||
- Do **not** try to reuse `get_items` with an empty `ParentId` for cross-library favourites; that endpoint is built as `?ParentId={}` ([online.rs:731](../../src-tauri/src/repository/online.rs#L731)) and an empty value is not a reliable "all libraries" request. Use `get_favorites`.
|
||||
- `SearchScope` is reused rather than a new `FavoritesScope` so there is one taxonomy expansion in the codebase, not two that can drift. If the name grates once favourites ship, rename the type across search + favourites in one commit — don't fork it.
|
||||
- `SearchScope::All` returns `None` from `item_types()` **on purpose**; callers must omit `IncludeItemTypes` entirely rather than sending a union (see the doc comment at [types.rs:316](../../src-tauri/src/repository/types.rs#L316)).
|
||||
- Ship order that keeps each step demonstrable: §1+§2 (state becomes visible) → §5 hearts (marking becomes possible) → §3+§5 browse surfaces (finding becomes possible) → §4 drain.
|
||||
- Regenerate `bindings.ts` after the Rust types change; never hand-edit it.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032
|
||||
**UX spec:** player quality selector — needs a `ux-flows.md` section before build
|
||||
**Related:** [catalog-index-search.md](catalog-index-search.md),
|
||||
[downloads-as-offline-library.md](downloads-as-offline-library.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Two things that are today tangled become explicitly separate:
|
||||
|
||||
- **The playback path** streams at a bitrate the viewer can change from the
|
||||
player. It is ephemeral and its rendition is volatile.
|
||||
- **The download path** fetches the whole file at one canonical quality, in the
|
||||
background, independently of whatever playback is doing.
|
||||
|
||||
Bytes fetched for playback are kept **only** when the playback rendition happens
|
||||
to be the same artifact the download path would produce — i.e. direct play.
|
||||
Otherwise playback bytes are discarded and the download path does its own fetch.
|
||||
|
||||
## Motivation
|
||||
|
||||
The appealing version of this — "stream and download at once, switch when enough
|
||||
has arrived" — breaks the moment the viewer can change bitrate. A capture taken
|
||||
while the rendition changes underneath it is a splice of two encodings: not a
|
||||
playable file, and not something that can be honestly recorded as a download.
|
||||
Once bitrate is selectable, one stream cannot serve both jobs.
|
||||
|
||||
Separating the paths also removes the thing that made the original idea
|
||||
expensive: there is no mid-playback source swap to engineer, because the download
|
||||
never has to take over the live session. It lands on disk and is used at the next
|
||||
natural boundary — next episode, or next time the item is played.
|
||||
|
||||
What exists already and is *not* this: `SmartCache` predictively downloads *other*
|
||||
items, `player_preload_upcoming` warms the next one, and
|
||||
`refresh_queue_local_sources` swaps queue entries to local at boundaries. All of
|
||||
it concerns items you are not currently playing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Available bitrate options for an item | **Rust** | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API. |
|
||||
| Mapping a chosen bitrate to transcode parameters | **Rust** | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it. |
|
||||
| Deciding whether playback bytes are keepable (direct play vs transcode) | **Rust** | Depends on the negotiated session. |
|
||||
| Canonical download quality | **Rust** | Policy over domain data. |
|
||||
| Cache eviction, storage budget, sparse-range bookkeeping | **Rust** | Storage policy. |
|
||||
| Promotion to a `downloads` row, and what invalidates a cache entry | **Rust** | Domain state. |
|
||||
| Rendering the quality selector; remembering the last choice | **Frontend** | Presentation and a view preference. The *list* comes from Rust. |
|
||||
| WiFi-only / opt-in toggles | **Frontend collects, Rust enforces** | The control is UI; the gate must hold even if the UI never calls. |
|
||||
|
||||
Borderline, recorded: the **default** playback bitrate could look like a user
|
||||
preference (frontend). It goes to Rust because it must be reconcilable with what
|
||||
the server can actually produce for a given media source — a preference the
|
||||
backend has to validate is not a preference the frontend can own alone. The
|
||||
frontend stores the user's *choice*; Rust decides what that choice resolves to.
|
||||
|
||||
## Design
|
||||
|
||||
### DR-121 — Bitrate selection in the player
|
||||
|
||||
The player exposes the qualities Rust reports for the current item. Changing it
|
||||
re-negotiates the stream URL at the new quality and resumes at the current
|
||||
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
|
||||
expected and acceptable, unlike the involuntary swap the earlier design would
|
||||
have needed.
|
||||
|
||||
Constraints that must not be broken:
|
||||
|
||||
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md
|
||||
records that returning `stream.mp4` means transcoded playback never starts.
|
||||
A quality change re-negotiates *within* HLS.
|
||||
- The quality→transcode-parameter mapping already exists in
|
||||
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
|
||||
Playback must call into the same mapping. Two copies of that table will drift.
|
||||
- Track selection (audio/subtitle) already survives a stream re-negotiation
|
||||
elsewhere in the player; a quality change must preserve it too.
|
||||
|
||||
### DR-122 — The playback path is ephemeral
|
||||
|
||||
Playback bytes are not persisted unless DR-124 says they are keepable. No partial
|
||||
capture is ever retained across a quality change: on change, any in-flight capture
|
||||
for that session is abandoned and its partial file deleted.
|
||||
|
||||
### DR-123 — The download path is independent
|
||||
|
||||
Downloading the whole file is a separate operation through the existing download
|
||||
manager, at one canonical quality (default `original`, the direct static copy),
|
||||
using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what
|
||||
the resumable download worker relies on. It is unaffected by what playback is
|
||||
doing, and playback is unaffected by it.
|
||||
|
||||
Once complete it becomes an ordinary download row, so everything already built on
|
||||
top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads
|
||||
page — picks it up with no further work.
|
||||
|
||||
**Prerequisite:** downloaded *video* is currently never played locally.
|
||||
`repository_get_video_stream_url` goes straight to the online repo and
|
||||
[player/[id]/+page.svelte:316](../../src/routes/player/[id]/+page.svelte#L316)
|
||||
calls it with no local check — so a completed video download is still streamed.
|
||||
This must be fixed or the whole feature is invisible for video.
|
||||
|
||||
### DR-124 — Keep playback bytes only when they *are* the download
|
||||
|
||||
Capture is enabled only where the played bytes and the canonical download artifact
|
||||
are the same thing — a **direct-play** session. Then:
|
||||
|
||||
| Path | Mechanism |
|
||||
|---|---|
|
||||
| Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id **and** media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk. |
|
||||
| Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing. |
|
||||
| Linux video (HLS transcode) | **Not captured.** Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead. |
|
||||
|
||||
Two abandonment rules, both of which must delete the partial rather than promote
|
||||
it:
|
||||
|
||||
- **Seek during an mpv capture.** `stream-record` is documented as intended for
|
||||
linear streams; seeking breaks the recording. Straight-through listening
|
||||
captures, scrubbing does not.
|
||||
- **Any quality change** (DR-122).
|
||||
|
||||
### DR-125 — Promotion, rendition, and invalidation
|
||||
|
||||
A capture is promoted to a `downloads` row (`status = 'completed'`) only when it
|
||||
covers the whole resource. Partial captures stay cache and remain evictable.
|
||||
|
||||
A new `downloads.source_rendition` column records the negotiated
|
||||
quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by
|
||||
the existing paths, which are always `original`. This is what makes an "upgrade to
|
||||
original" action possible later, and what stops a 720p capture and a 4K download
|
||||
being indistinguishable rows.
|
||||
|
||||
**Invalidation.** A quality change never touches a file that already exists —
|
||||
neither a permanent download nor a completed temporary one. Both remain valid
|
||||
copies of the rendition they hold, and deleting either would throw away bytes
|
||||
already paid for.
|
||||
|
||||
What a quality change *does* invalidate is an **in-flight** capture or background
|
||||
download of cached media: it is abandoned and restarted at the newly chosen
|
||||
quality, because a capture spanning a rendition change is a splice of two
|
||||
encodings rather than a playable file (DR-122).
|
||||
|
||||
So the rule is about *ongoing* work, not stored files. Nothing in this spec
|
||||
deletes user data.
|
||||
|
||||
### Gating
|
||||
|
||||
Capture and background download obey the existing WiFi-only gate and storage
|
||||
budget, and are off unless opted in. Enforcement is in Rust.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Mid-playback switch onto a completing download.** Two independent paths make
|
||||
it unnecessary; the download is used from the next boundary.
|
||||
- **Backfilling the unplayed remainder of a capture.** Watch 40 minutes and you
|
||||
have 40 minutes; completing it needs sparse-range bookkeeping and a resumable
|
||||
tail fetch. The DR-123 download path already produces a complete file, which is
|
||||
the reason this can wait.
|
||||
- **Bundling ffmpeg** to make transcoded video capturable. Real option, large
|
||||
packaging decision, its own proposal.
|
||||
- **Routing Linux video playback through `stream.mp4`.** Regresses a documented,
|
||||
hard-won fix.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The player offers the qualities Rust reports, and changing one resumes at
|
||||
the same position with audio/subtitle selection preserved.
|
||||
- [ ] A quality change abandons any in-flight capture and leaves no partial file.
|
||||
- [ ] A quality change never deletes a `downloads` row.
|
||||
- [ ] A completed background download of a video is *played from disk* on the next
|
||||
play (the DR-123 prerequisite).
|
||||
- [ ] A direct-play session played start-to-finish leaves a complete local file
|
||||
with no second fetch; replaying it fetches no media bytes.
|
||||
- [ ] Seeking during an mpv capture abandons it; no truncated file is promoted.
|
||||
- [ ] A transcoded Linux video session is never captured, and never partially
|
||||
promoted.
|
||||
- [ ] Promoted rows record their rendition; existing paths still record
|
||||
`NULL`/`original`.
|
||||
- [ ] Gates hold with the setting off *and* with the frontend never sending it.
|
||||
- [ ] Eviction cannot delete bytes backing a promoted download row.
|
||||
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`,
|
||||
`bun run test:rust`, `bun run check:boundary` pass; `bindings.ts`
|
||||
regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
Rust, table-driven and pure where possible: quality→params resolution shared with
|
||||
the download path; keepability (direct play vs transcode vs gate off); promotion
|
||||
(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not);
|
||||
invalidation (evicts cache, never a download row); rendition round-trip.
|
||||
|
||||
Android: instrumented — a played direct-play item yields cache entries, and a
|
||||
replay issues no media network request.
|
||||
|
||||
Frontend: the quality list renders from backend data with no item-type or
|
||||
codec taxonomy in `src/`; the selector's remembered choice is a view preference.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
|
||||
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
||||
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
||||
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
||||
| Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 \| DR-125` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **A parallel Claude session is active in this repo.** `git diff` before
|
||||
"repairing" anything you did not write.
|
||||
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
|
||||
- Reuse the smart-cache storage budget; two budgets over one disk is how devices
|
||||
fill up.
|
||||
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion
|
||||
for an item that was never cached.
|
||||
- Build DR-123's local-playback fix first. Without it nothing in this spec is
|
||||
observable for video.
|
||||
@@ -0,0 +1,239 @@
|
||||
# Spec: series navigation lands on the current episode
|
||||
|
||||
**Status:** Accepted
|
||||
**Requirements:** UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106
|
||||
**UX spec:** [ux-flows.md §5B.1](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Opening a TV series lands you where you actually are in it: the seasons render
|
||||
as collapsible sections with **only the current season expanded**, the current
|
||||
episode highlighted and scrolled into view, and the hero button opens that
|
||||
episode's focus view (labelled `Resume S2E4` / `Play S1E1`) instead of the first
|
||||
season. A season stops being a destination of its own — every route that used to
|
||||
land on `/library/<seasonId>` now lands on the series with that season in view,
|
||||
so the full cross-season episode list is always reachable in one place. Watch
|
||||
history can be erased per series and per season. Separately, each video library
|
||||
collapses from three routes (landing, all-titles, genres) to one route with
|
||||
in-page tabs.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two problems, reported together.
|
||||
|
||||
**1. Series navigation dead-ends at season 1.** The series detail page's Play
|
||||
button resolved its target as `$libraryItems[0]` — the first *season* child,
|
||||
ordered by `SortName` — and navigated to `/player/<seasonId>`. The player route
|
||||
classifies `season` as a container kind and bounces it back to
|
||||
`/library/<seasonId>`. So Play on a series played nothing; it navigated you to
|
||||
the season-1 page. Opening a series without pressing Play rendered every season
|
||||
stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past
|
||||
everything they had already watched.
|
||||
|
||||
The backend has been able to answer "where is this viewer in this show" the
|
||||
whole time: `repository_get_next_up_episodes(handle, series_id, limit)` is wired
|
||||
end-to-end to `/Shows/NextUp?SeriesId=`. **Both frontend call sites pass
|
||||
`undefined` for `series_id`** — the per-series capability existed and was never
|
||||
used.
|
||||
|
||||
**2. Seasons are an accidental page.** There is no season route. `/library/
|
||||
<seasonId>` falls through the detail page's `kind` chain into the generic
|
||||
"Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season
|
||||
must render as a row list). Worse, clicking an episode from that grid opens a
|
||||
*bare* Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the
|
||||
episode breadcrumb, `handleItemClick case "season"`, the TV landing page, and
|
||||
the broken Play button above.
|
||||
|
||||
**3. Too many video library routes.** Seven routes serve two media types, and the
|
||||
naming does not even agree with itself: `/library/tv` + `/library/tv/shows` +
|
||||
`/library/shows/genres` versus `/library/movies` + `/library/movies/all` +
|
||||
`/library/movies/genres`. The genre routes do not share a prefix, which
|
||||
`searchScope.ts:45` carries an apologetic comment about. The two "all" pages are
|
||||
27-line config wrappers over the same `GenericMediaListPage`.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which episode is "current" for a series (resume → next-up → first unwatched → first) | **Rust** | Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what `UserData.is_played` means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned. |
|
||||
| Gathering a series' episodes across all seasons in broadcast order | **Rust** | Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out *and* its flat-series fallback; that is domain knowledge that leaked. |
|
||||
| Ordering rule for "series order" (season index, then episode index, specials last) | **Rust** | Season 0 = specials is a Jellyfin convention, not a layout choice. |
|
||||
| Scrolling the current episode into view; the highlight ring and `Up next` badge | Frontend | Pure presentation. Changes only if the page is redesigned. |
|
||||
| Which seasons start expanded | Frontend | Consumes the backend's answer (`currentEpisode`) to decide layout. The *decision* about where the viewer is stays in Rust; only "and therefore this section opens" is here. |
|
||||
| What "erase watch history" means (played flag + resume position, recursive over a container) | **Rust** | Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign. |
|
||||
| Refusing to clear history while offline | **Rust** | A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top. |
|
||||
| Play button *label* (`Resume S2E4` vs `Play S1E1`) | Frontend | Rendering a decision the backend already made (the returned episode plus its resume position). |
|
||||
| Which route Play navigates to | Frontend | Navigation is presentation. |
|
||||
| Redirecting `/library/<seasonId>` to the series anchor | Frontend | Route topology. |
|
||||
| Episode-strip window size (3 before / 6 after) | Frontend | A layout constant; §5B.2 owns it. |
|
||||
| Library page tabs and the `?view=` param | Frontend | View preference and route topology. |
|
||||
|
||||
Borderline row — **the strip's cross-season *ordering*** is Rust (it is series
|
||||
order, above), but the *window* taken from that ordered list is frontend. The
|
||||
tie-breaker: the list handed to the frontend is already correct and complete;
|
||||
choosing how much of it fits on screen is layout.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust: the current-episode policy
|
||||
|
||||
Two new pieces, split so the policy is unit-testable without a repository.
|
||||
|
||||
**Pure policy** — `src-tauri/src/repository/series_progress.rs`:
|
||||
|
||||
```rust
|
||||
/// Series order: season index asc, then episode index asc. Specials (season 0)
|
||||
/// sort after every numbered season rather than before season 1.
|
||||
pub fn sort_series_order(episodes: &mut [MediaItem]);
|
||||
|
||||
/// The episode a viewer should land on, given everything already fetched.
|
||||
/// Order: in-progress episode → Next Up → first unwatched → first episode.
|
||||
pub fn pick_current_episode(
|
||||
episodes: &[MediaItem], // series order
|
||||
next_up: &[MediaItem],
|
||||
resume: &[MediaItem],
|
||||
) -> Option<MediaItem>;
|
||||
```
|
||||
|
||||
Why that order:
|
||||
|
||||
- **In-progress wins** because a partially-watched episode is literally where
|
||||
the viewer stopped; Next Up would skip past it. Ties break toward the earliest
|
||||
in series order, so a viewer who dipped into a later episode still resumes the
|
||||
one they are actually working through.
|
||||
- **Next Up second** because it is the server's own answer, and it accounts for
|
||||
history we do not cache.
|
||||
- **First unwatched third** — the offline repository returns an empty vec for
|
||||
Next Up (`offline.rs:1247`), so without this fallback the whole feature would
|
||||
be online-only. This is the offline path, not dead code.
|
||||
- **First episode last** so a never-watched series lands on S1E1 rather than
|
||||
nothing.
|
||||
|
||||
A `resume`/`next_up` entry that is not among `episodes` is still honoured — it
|
||||
comes from the same server and may carry an id the season fan-out missed — but
|
||||
it must belong to this series.
|
||||
|
||||
**Fetch + command** — `src-tauri/src/commands/repository.rs`:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_episodes(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Vec<MediaItem>, String>
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_current_episode(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Option<MediaItem>, String>
|
||||
```
|
||||
|
||||
Frontend params are camelCase (`{ handle, seriesId }`) per the Tauri v2 rule.
|
||||
|
||||
`repository_get_series_episodes` performs the fan-out the frontend used to do:
|
||||
`get_items(series_id)` → seasons → `get_items(season_id)` per season, plus the
|
||||
flat-series fallback (a series whose children are episodes, not seasons), then
|
||||
`sort_series_order`. `repository_get_series_current_episode` calls it, adds
|
||||
`get_next_up_episodes(Some(series_id), Some(1))` and
|
||||
`get_resume_items(Some(series_id), Some(10))`, and applies `pick_current_episode`.
|
||||
Both tolerate a failing Next Up (offline) by treating it as empty rather than
|
||||
failing the whole call.
|
||||
|
||||
### Frontend: series page
|
||||
|
||||
- `loadItem()` calls `repositoryGetSeriesEpisodes` once instead of fanning out
|
||||
over seasons itself, and `repositoryGetSeriesCurrentEpisode` for the anchor.
|
||||
Season *headers* still come from `get_items(seriesId)`; the page groups the
|
||||
returned episodes under them by `parentIndexNumber`.
|
||||
- No `?episode=` param → series view, `SeasonSection` receives
|
||||
`currentEpisodeId`, `EpisodeRow` renders the highlight and scrolls itself into
|
||||
view (`scrollIntoView({ block: "center" })`, the existing `focused` mechanism,
|
||||
now distinguishing *focused* from *current*).
|
||||
- Seasons are collapsible and **only the current season is expanded**
|
||||
(`initialExpandedSeasons`). Without this a ten-season show renders every
|
||||
episode of every season at once and buries the one the viewer came for. A
|
||||
collapsed season still shows its episode count and watched count, so progress
|
||||
is legible without expanding. Toggle state is local and not persisted — it is
|
||||
a reading position, not a preference.
|
||||
- Hero Play → `goto(/library/<seriesId>?episode=<currentId>)`, i.e. the Episode
|
||||
Focus View, where an explicit Play/Resume starts playback. This follows
|
||||
ux-flows §5B.5's "tap opens, never commits" rule: Play on a *container* is
|
||||
navigation; Play on a *leaf* (the focus view, a movie) commits.
|
||||
- Clicking an episode in a season section → `?episode=` swap, not
|
||||
`/player/<id>`. §5B.1.
|
||||
|
||||
### Frontend: seasons are not a destination
|
||||
|
||||
`/library/<seasonId>` resolves the season's `seriesId` and redirects to
|
||||
`/library/<seriesId>#season-<indexNumber>`; `SeasonSection` renders that anchor
|
||||
id. A season with no `seriesId` (deep link into a stale cache) keeps the old
|
||||
generic rendering as a fallback so the user is never stranded. Inbound links
|
||||
updated: episode breadcrumb, `handleItemClick case "season"`, the TV landing
|
||||
page's `case "Season"`, and `DownloadedBrowse`.
|
||||
|
||||
### Erasing watch history
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_clear_watch_history(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
`OnlineRepository` maps it to `DELETE /Users/{userId}/PlayedItems/{itemId}` —
|
||||
Jellyfin's mark-unplayed, which clears the played flag *and* zeroes the resume
|
||||
position, and which the server applies recursively to a folder. One call
|
||||
therefore handles a whole series or a single season; no per-episode fan-out.
|
||||
`OfflineRepository` returns `RepoError::Offline` rather than clearing locally,
|
||||
because divergent local history is undone by the next sync.
|
||||
|
||||
`ClearHistoryButton` is shared by the series hero (`scope="series"`) and each
|
||||
`SeasonSection` header (`scope="season"`). It confirms first — there is no undo —
|
||||
disables itself while the server is unreachable, and reloads the page on success
|
||||
so the recomputed current episode is what the viewer sees. Clearing a whole
|
||||
series therefore returns it to S1E1, which is the same path a never-watched
|
||||
series takes through `pick_current_episode`.
|
||||
|
||||
### Frontend: one route per video library
|
||||
|
||||
`/library/tv` and `/library/movies` each gain `?view=browse|all|genres` tabs,
|
||||
rendering the existing `GenericMediaListPage` / `GenericGenreBrowser` components
|
||||
inline. `?view=` is omitted for `browse` (the default) to keep URLs clean —
|
||||
the same convention `searchRouteUrl` uses for the `all` scope.
|
||||
|
||||
The four legacy routes become redirect-only `+page.ts` loads:
|
||||
|
||||
| Legacy | Redirects to |
|
||||
|--------|--------------|
|
||||
| `/library/tv/shows` | `/library/tv?view=all` |
|
||||
| `/library/shows/genres` | `/library/tv?view=genres` |
|
||||
| `/library/movies/all` | `/library/movies?view=all` |
|
||||
| `/library/movies/genres` | `/library/movies?view=genres` |
|
||||
|
||||
They are kept (rather than deleted) because `GenreTags` builds links to them and
|
||||
users may have them in history. `resolveSearchScope` keeps its `/library/shows`
|
||||
branch for the same reason.
|
||||
|
||||
The "Browse" tile grid at the bottom of both landing pages is removed — the tabs
|
||||
replace it, and the tiles were a second navigation affordance to the same two
|
||||
destinations the carousels' "Show all" links already reach.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Cross-season autoplay.** `player/mod.rs:fetch_next_episode_for_item` is
|
||||
still season-bounded, so autoplay stops at a season boundary. Fixing it should
|
||||
reuse `repository_get_series_episodes`, but it touches the playback state
|
||||
machine and the Android JNI advance path (see the `AutoplayDecision` deadlock
|
||||
note in CLAUDE.md) and belongs in its own change.
|
||||
- **Music library routes.** `/library/music/*` has five sub-routes with the same
|
||||
shape; the same consolidation applies but is not done here.
|
||||
- **Marking a series' progress** (mark-watched / mark-unwatched from the series
|
||||
page).
|
||||
+1304
-780
File diff suppressed because it is too large
Load Diff
+150
-3
@@ -634,7 +634,7 @@ episode strip.
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ │ [▶ Play] [⬇] [♡] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
@@ -688,10 +688,10 @@ A movie has no continuation set, so cast follows the hero directly.
|
||||
### 5B.4 Series detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Seasons (collapsible; only the current season expanded)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
@@ -700,6 +700,29 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||
The reason a user opens a series page is to pick an episode; discovery content
|
||||
is secondary and sits underneath.
|
||||
|
||||
**Rules for the seasons block** *(UR-062, UR-064)*:
|
||||
|
||||
- **The page opens where the viewer is.** The backend resolves the current
|
||||
episode — in progress, else Next Up, else first unwatched, else the premiere —
|
||||
and the page scrolls it into view with an `Up next` badge and a highlight ring.
|
||||
Never season 1 by default, unless season 1 *is* where the viewer is.
|
||||
- **Seasons collapse; only the current one is expanded.** A ten-season show
|
||||
otherwise renders hundreds of rows and buries the episode the viewer came for.
|
||||
A collapsed season still names its episode count and watched count, so
|
||||
progress is readable without expanding it.
|
||||
- **The hero button opens, it does not play.** It reads `Resume S2E4` /
|
||||
`Play S1E1` — naming its target — and navigates to that episode's Focus View,
|
||||
where Play commits. Play on a *container* is navigation (§5B.5); Play on a
|
||||
*leaf* is the commitment.
|
||||
- **A season is never its own page.** `/library/<seasonId>` redirects to
|
||||
`/library/<seriesId>#season-N`. Every affordance that names a season — the
|
||||
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
|
||||
the series with that season in view, so the episodes of all seasons stay one
|
||||
browsable list.
|
||||
- **Watch history is erasable** per series (hero) and per season (season
|
||||
header). It confirms first, cannot be undone, and needs the server. Clearing a
|
||||
whole series returns it to S1E1 by the same path a never-watched show takes.
|
||||
|
||||
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||
|
||||
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||
@@ -734,6 +757,130 @@ opt-in. Grids and other surfaces keep tap-to-open with no long-press.
|
||||
|
||||
---
|
||||
|
||||
## 5C. Favourites
|
||||
|
||||
Favouriting is a two-sided promise: the heart takes the input, and the app must
|
||||
be able to give it back. This section covers both sides — where you can mark a
|
||||
favourite, and where marked favourites resurface.
|
||||
|
||||
See [specs/favorites-browsing.md](specs/favorites-browsing.md) for the layer
|
||||
assignment and wire shapes.
|
||||
|
||||
### 5C.1 The heart appears wherever an item does
|
||||
|
||||
A favourite is a property of an *item*, so the affordance follows the item
|
||||
rather than living on one privileged screen. Any surface that shows a whole
|
||||
item shows its heart.
|
||||
|
||||
| Surface | Heart position | Notes |
|
||||
|---------|----------------|-------|
|
||||
| Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4 |
|
||||
| Episode Focus View hero | Same row as Play / Download | §5B.2 |
|
||||
| Album, Artist, Playlist detail | In the header button row | §5.2 |
|
||||
| Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards |
|
||||
| Mini player | Right of the track metadata | Existing behaviour, unchanged |
|
||||
| Full player | Secondary controls row | §3.2 — **not yet built**, see §5C.5 |
|
||||
|
||||
Rules:
|
||||
|
||||
- **The heart never competes with the card.** On a media card it is its own
|
||||
button and swallows the tap, so hearting an item never also opens or plays
|
||||
it, and never triggers the §5B.5 long-press.
|
||||
- **State is shown, not guessed.** A filled heart means the *server* considers
|
||||
the item a favourite (or you just tapped it). An item favourited in Jellyfin
|
||||
Web, on another device, or by another client renders filled here without
|
||||
being touched in JellyTau.
|
||||
- **Feedback is immediate.** The heart fills on tap and a toast confirms;
|
||||
neither waits for the server round-trip.
|
||||
|
||||
### 5C.2 Three ways back to what you favourited
|
||||
|
||||
Favourites are not one destination — they are a lens, and the right surface
|
||||
depends on whether the user is *browsing*, *deciding*, or *hunting*.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User[User wants their favourites] --> How{Intent}
|
||||
|
||||
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
|
||||
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
|
||||
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
|
||||
|
||||
Home -->|See all| Page
|
||||
Page --> Detail[Item detail page]
|
||||
Filter --> Detail
|
||||
```
|
||||
|
||||
**Home carousels.** Rows for favourite movies, shows and music sit below
|
||||
*Recently Added*. A row with nothing in it **does not render** — a fresh install
|
||||
shows no empty favourite rows. Each row ends with *See all*, landing on the
|
||||
matching tab of the Favourites page.
|
||||
|
||||
**The Favourites page** (`/library/favorites`) is the complete collection,
|
||||
scoped by tabs:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [←] Favourites │
|
||||
│ ┌─────┬────────┬───────┬───────┐ │
|
||||
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
|
||||
│ └─────┴────────┴───────┴───────┘ │
|
||||
│ │
|
||||
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
|
||||
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
|
||||
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Cards obey §5A in full — shape follows the media, so a mixed *All* tab reads
|
||||
as posters, squares and thumbnails side by side rather than one forced shape.
|
||||
- Reached from a card on the library overview (`/library`) and from *See all*
|
||||
on any home favourites row.
|
||||
- Sorted by name. Jellyfin does not record *when* an item was favourited, so
|
||||
"recently favourited" is not offerable — see §5C.5.
|
||||
- Empty state, per tab: *"Nothing favourited yet — tap the heart on anything
|
||||
you like."*
|
||||
|
||||
**The in-library filter** is for narrowing where the user already is: a
|
||||
favourites toggle in the header of the Movies, TV and Music browse pages,
|
||||
filtering the current list in place. It is **session-scoped and not persisted** —
|
||||
a sticky filter that silently hides most of a library reads as data loss on the
|
||||
next launch.
|
||||
|
||||
### 5C.3 Removing a favourite removes it everywhere, at once
|
||||
|
||||
Un-hearting an item on the Favourites page removes its card from the grid
|
||||
immediately; the same item disappears from the home rows and shows an empty
|
||||
heart on its detail page without a manual refresh. The reverse holds for
|
||||
favouriting. There is no confirmation prompt — the action is one tap to undo.
|
||||
|
||||
### 5C.4 Offline
|
||||
|
||||
- **Marking works offline.** The heart fills, the toast confirms, and the change
|
||||
is held locally.
|
||||
- **It reaches the server on reconnect**, without the user returning to the
|
||||
screen where they made it.
|
||||
- **Browsing offline shows favourites among media on the device**, subject to
|
||||
the same "Show all server media" gate as every other browse surface (§7.2) —
|
||||
with the gate off, an empty Favourites tab means *nothing favourited is
|
||||
downloaded*, and the page does not quietly fall back to the server catalog.
|
||||
|
||||
### 5C.5 Known deviations
|
||||
|
||||
- **The full player has no heart.** §3.2 and §3.3 list a Favorite button among
|
||||
the full player's secondary controls; it was never built, and this pass does
|
||||
not add it. The mini player heart above it is the only in-player affordance.
|
||||
*(UR-067)*
|
||||
- **No "recently favourited" sort.** Jellyfin's API does not expose a favourite
|
||||
timestamp, so favourites can only be ordered by name. Recording the
|
||||
timestamp locally at toggle time would order *this device's* favourites only,
|
||||
which is worse than a consistent name sort.
|
||||
- **Music is one tab, not three.** The Music scope mixes albums, artists and
|
||||
tracks in a single grid rather than offering sub-tabs. Acceptable while
|
||||
favourite counts are small; revisit if the tab becomes unscannable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
|
||||
Search is **context-scoped**: what you are looking at when you start a search
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.2.1",
|
||||
"version": "0.4.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -20,6 +20,8 @@
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:device": "./scripts/build-android.sh --device",
|
||||
"android:build:release:device": "./scripts/build-android.sh release --device",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
"android:deploy": "./scripts/deploy-android.sh",
|
||||
"android:dev": "./scripts/build-and-deploy.sh",
|
||||
|
||||
@@ -18,15 +18,50 @@ echo ""
|
||||
# Parse args: build type (debug/release) and optional --clean flag.
|
||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||
#
|
||||
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
|
||||
# which is what a distributable universal APK needs — but for an on-device test
|
||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||
# only the connected device's architecture; --abi <t> targets one explicitly.
|
||||
BUILD_TYPE="debug"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
ABI="${ABI:-}"
|
||||
next_is_abi=0
|
||||
for arg in "$@"; do
|
||||
if [ "$next_is_abi" = "1" ]; then
|
||||
ABI="$arg"
|
||||
next_is_abi=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--clean) CLEAN=1 ;;
|
||||
--abi) next_is_abi=1 ;;
|
||||
--device) ABI="device" ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve --device to the attached device's Rust target triple.
|
||||
if [ "$ABI" = "device" ]; then
|
||||
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
||||
case "$device_abi" in
|
||||
arm64-v8a) ABI="aarch64" ;;
|
||||
armeabi-v7a) ABI="armv7" ;;
|
||||
x86_64) ABI="x86_64" ;;
|
||||
x86) ABI="i686" ;;
|
||||
*)
|
||||
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
|
||||
ABI=""
|
||||
;;
|
||||
esac
|
||||
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
|
||||
fi
|
||||
|
||||
TARGET_ARGS=()
|
||||
if [ -n "$ABI" ]; then
|
||||
TARGET_ARGS=(--target "$ABI")
|
||||
fi
|
||||
|
||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "🧹 Clearing build caches (clean build)..."
|
||||
@@ -48,10 +83,10 @@ if [ "$BUILD_TYPE" = "release" ]; then
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
echo "📦 Building release APK..."
|
||||
bun run tauri android build --apk true
|
||||
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||
else
|
||||
echo "📦 Building debug APK..."
|
||||
bun run tauri android build --apk true --debug
|
||||
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(61);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(91);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(213);
|
||||
expect(defined.UR).toBe(71);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(127);
|
||||
expect(defined.JA).toBe(34);
|
||||
expect(defined.total).toBe(264);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.2.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.2.1"
|
||||
version = "0.4.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# breaking the PiP button in release builds only.
|
||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||
-keep class com.dtourolle.jellytau.WindowInsetsBridge { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ class MainActivity : TauriActivity() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// enableEdgeToEdge() puts the WebView under the status bar, the navigation/
|
||||
// gesture bar and the display cutout — and targeting SDK 36 makes that
|
||||
// non-optional anyway. Android WebView never surfaces the *system bar*
|
||||
// insets to CSS (only the display cutout), so the web layer has to be told.
|
||||
// Without this the bottom nav renders underneath the navigation bar, badly
|
||||
// so on devices with a tall opaque 3-button bar. (UR-066)
|
||||
WindowInsetsBridge.install(this)
|
||||
|
||||
// Configure WebView for media playback after Tauri initialization
|
||||
handler.postDelayed({
|
||||
configureWebViewForMedia()
|
||||
@@ -179,6 +187,12 @@ class MainActivity : TauriActivity() {
|
||||
//
|
||||
// The settings/WebChromeClient work below is idempotent and must keep
|
||||
// running on resume; only the bridge injection is one-shot.
|
||||
|
||||
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
||||
// idempotent and MUST re-run: a page load discards the inline style the
|
||||
// last push set, so the WebView would otherwise be left with no insets.
|
||||
WindowInsetsBridge.attachWebView(webView)
|
||||
|
||||
if (webView === bridgesInstalledOn) {
|
||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||
configureWebViewSettings(webView)
|
||||
@@ -257,6 +271,11 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Window insets (safe areas). The push path above races the page load, so
|
||||
// the frontend pulls the current values on mount through this bridge.
|
||||
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidInsets' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
/**
|
||||
* Publishes the Activity's real window insets to the WebView as CSS custom
|
||||
* properties.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112
|
||||
*
|
||||
* ## Why this is necessary
|
||||
*
|
||||
* MainActivity calls `enableEdgeToEdge()`, and the app targets SDK 36 —
|
||||
* edge-to-edge is mandatory from SDK 35 and the opt-out is ignored from SDK 36
|
||||
* — so the Tauri WebView always spans the whole window, underneath the status
|
||||
* bar, the navigation/gesture bar and the display cutout.
|
||||
*
|
||||
* The web layer cannot discover that by itself. Android WebView maps only the
|
||||
* **display cutout** into `env(safe-area-inset-*)` (and only with
|
||||
* `viewport-fit=cover`); the status bar and navigation bar are never reported.
|
||||
* Unlike iOS Safari there is no CSS-visible system-bar inset. So the frontend's
|
||||
* `env()`-based padding evaluated to 0 on every device and the bottom nav
|
||||
* rendered underneath the navigation bar.
|
||||
*
|
||||
* How badly that showed depended entirely on the device's navigation mode: a
|
||||
* thin translucent gesture pill overlaps almost harmlessly, while a tall opaque
|
||||
* 3-button bar swallows the nav outright.
|
||||
*
|
||||
* ## Contract with the frontend
|
||||
*
|
||||
* Insets are reported in **CSS pixels** (density-independent), because that is
|
||||
* the unit CSS will use them in — dividing by `displayMetrics.density` here is
|
||||
* what keeps the padding correct across screen densities.
|
||||
*
|
||||
* - **Push**: on every inset change (rotation, navigation-mode switch, PiP
|
||||
* enter/exit) the four `--jt-inset-*` custom properties are written onto
|
||||
* `document.documentElement` and `jellytau-insets-changed` is dispatched.
|
||||
* - **Pull**: `AndroidInsets.get()` returns the same payload as JSON. Required
|
||||
* because the first inset pass normally lands before the SvelteKit document
|
||||
* exists, and a page load discards any inline style a push had set.
|
||||
*
|
||||
* See `src/lib/utils/safeArea.ts` and the `--safe-*` vars in `src/app.css`.
|
||||
*/
|
||||
object WindowInsetsBridge {
|
||||
|
||||
/** Latest insets in CSS pixels. Written on the main thread, read from the WebView binder thread. */
|
||||
@Volatile
|
||||
private var top = 0
|
||||
@Volatile
|
||||
private var right = 0
|
||||
@Volatile
|
||||
private var bottom = 0
|
||||
@Volatile
|
||||
private var left = 0
|
||||
|
||||
/** Cached so a WebView found later (or re-found on resume) can be primed. */
|
||||
private var webView: WebView? = null
|
||||
|
||||
/**
|
||||
* Start listening for window insets on [activity].
|
||||
*
|
||||
* Call from `onCreate` right after `enableEdgeToEdge()`. The listener
|
||||
* returns the insets **unconsumed** so the WebView still receives them for
|
||||
* its own display-cutout handling.
|
||||
*/
|
||||
fun install(activity: Activity) {
|
||||
val density = activity.resources.displayMetrics.density
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(activity.window.decorView) { _, insets ->
|
||||
// systemBars() covers the status bar and the navigation/gesture bar;
|
||||
// displayCutout() covers notches and punch-holes, which in landscape
|
||||
// land on a side edge that systemBars() does not describe.
|
||||
val i = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
|
||||
val toCssPx = { px: Int -> if (density > 0f) Math.round(px / density) else px }
|
||||
top = toCssPx(i.top)
|
||||
right = toCssPx(i.right)
|
||||
bottom = toCssPx(i.bottom)
|
||||
left = toCssPx(i.left)
|
||||
|
||||
android.util.Log.d(
|
||||
"WindowInsetsBridge",
|
||||
"insets (css px): top=$top right=$right bottom=$bottom left=$left"
|
||||
)
|
||||
push()
|
||||
|
||||
// Do NOT return CONSUMED - other views (and the WebView's own cutout
|
||||
// handling) still need to see these.
|
||||
insets
|
||||
}
|
||||
|
||||
// The first pass may already have happened before the listener existed.
|
||||
ViewCompat.requestApplyInsets(activity.window.decorView)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the WebView carrying the UI and push the current insets into it.
|
||||
*
|
||||
* Safe to call repeatedly (MainActivity re-finds the WebView on every
|
||||
* resume): this only writes CSS properties, unlike `addJavascriptInterface`,
|
||||
* which must run exactly once per WebView.
|
||||
*/
|
||||
fun attachWebView(view: WebView) {
|
||||
webView = view
|
||||
push()
|
||||
}
|
||||
|
||||
/** Current insets as JSON in CSS pixels — the payload `AndroidInsets.get()` returns. */
|
||||
fun currentJson(): String =
|
||||
"""{"top":$top,"right":$right,"bottom":$bottom,"left":$left}"""
|
||||
|
||||
/** The `AndroidInsets` @JavascriptInterface object for the pull path. */
|
||||
fun jsInterface(): Any = object : Any() {
|
||||
@JavascriptInterface
|
||||
fun get(): String = currentJson()
|
||||
}
|
||||
|
||||
/** Write the custom properties into the live document and signal the change. */
|
||||
private fun push() {
|
||||
val view = webView ?: return
|
||||
val js = """
|
||||
(function() {
|
||||
var s = document.documentElement.style;
|
||||
s.setProperty('--jt-inset-top', '${top}px');
|
||||
s.setProperty('--jt-inset-right', '${right}px');
|
||||
s.setProperty('--jt-inset-bottom', '${bottom}px');
|
||||
s.setProperty('--jt-inset-left', '${left}px');
|
||||
window.dispatchEvent(new CustomEvent('jellytau-insets-changed'));
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
view.post { view.evaluateJavascript(js, null) }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme -->
|
||||
<!--
|
||||
Base application theme.
|
||||
|
||||
This app is EDGE-TO-EDGE: MainActivity calls enableEdgeToEdge(), and
|
||||
targeting SDK 36 makes it mandatory anyway (enforced from SDK 35, with the
|
||||
opt-out ignored from SDK 36). The WebView therefore spans the whole window,
|
||||
under the status bar, the navigation/gesture bar and the display cutout.
|
||||
|
||||
This theme used to declare `android:fitsSystemWindows=true` with a "don't
|
||||
draw behind system bars" comment. That was never true: enableEdgeToEdge()
|
||||
calls setDecorFitsSystemWindows(false) at runtime and wins, and the
|
||||
platform ignores the attribute at this target SDK regardless. Leaving it
|
||||
in only hid the fact that nothing was insetting the content.
|
||||
|
||||
Insets are handled where they can actually be honoured: WindowInsetsBridge
|
||||
reads them and hands them to CSS as jt-inset custom properties.
|
||||
See UR-066 / DR-112.
|
||||
-->
|
||||
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Status bar color -->
|
||||
<!-- System bars are transparent; the app draws its own background behind
|
||||
them (e.g. BottomUi's surface extends under the gesture bar). -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<!-- Make status bar icons dark or light based on background -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<!-- Light icons on our dark background, both bars. -->
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||
<!-- Don't draw behind status bar -->
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<!-- Ensure content doesn't extend into system bars -->
|
||||
<item name="android:fitsSystemWindows">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
||||
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, Manager, State};
|
||||
|
||||
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
@@ -29,16 +31,79 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
/// full-catalog sync.
|
||||
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
||||
|
||||
/// How long an index stays fresh before a re-index is due.
|
||||
///
|
||||
/// This lives in Rust rather than being a frontend constant because it decides
|
||||
/// *whether the local cache is authoritative* — the same class of decision as
|
||||
/// `include_catalog_browse`, and squarely the "sync policy" the spec review
|
||||
/// checklist keeps out of the presentation layer. If it later becomes
|
||||
/// user-configurable it stays a Rust-owned setting edited through a command.
|
||||
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
|
||||
/// How often the scheduler wakes to *check* staleness. Far shorter than the TTL
|
||||
/// because a tick is nearly free — one indexed `app_settings` lookup — and it is
|
||||
/// what makes the indexer responsive to events it cannot subscribe to: signing
|
||||
/// in, and coming back online. The TTL, not the tick, decides whether a crawl
|
||||
/// actually happens.
|
||||
const CATALOG_INDEX_TICK: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Delay before the first staleness check, to let sign-in complete and the
|
||||
/// repository be registered. Without it the first check runs against an empty
|
||||
/// repository manager and a fresh install would sit unindexed until the next
|
||||
/// tick.
|
||||
const CATALOG_INDEX_FIRST_CHECK: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Kebab-case, per the project's event convention.
|
||||
pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
|
||||
|
||||
/// Guards against two passes running at once. Replaces the frontend's
|
||||
/// `syncInProgress` boolean in `offlineCatalog.ts`, which could not see a pass
|
||||
/// started by the scheduler.
|
||||
static INDEX_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Clears [`INDEX_IN_PROGRESS`] however the pass leaves — including on the `?`
|
||||
/// early return when `get_libraries` fails, which a plain store at the end of
|
||||
/// the function would leak.
|
||||
struct IndexPassGuard;
|
||||
|
||||
impl Drop for IndexPassGuard {
|
||||
fn drop(&mut self) {
|
||||
INDEX_IN_PROGRESS.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress of a background index pass, for the staleness hint in the UI.
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogIndexEvent {
|
||||
/// `started` | `finished` | `failed`
|
||||
pub state: String,
|
||||
pub items_cached: usize,
|
||||
pub items_pruned: usize,
|
||||
pub libraries_failed: usize,
|
||||
/// Present on `failed`.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Item types worth caching for offline browsing: containers the library
|
||||
/// landing pages render plus the playable leaves users queue for download.
|
||||
/// `MusicArtist` and `Playlist` are here because search groups results by them
|
||||
/// (UR-060's Artists group). Without them in the crawl, the local index can
|
||||
/// never answer an artist query and those groups can only ever be filled by the
|
||||
/// server leg. Keep this in step with what `prune_stale_catalog` is allowed to
|
||||
/// sweep — the crawl is only authoritative for the types it asks for.
|
||||
///
|
||||
/// TRACES: UR-065, UR-060 | DR-111
|
||||
const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||
"MusicAlbum",
|
||||
"MusicArtist",
|
||||
"Movie",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Audio",
|
||||
"BoxSet",
|
||||
"Playlist",
|
||||
];
|
||||
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -48,6 +113,9 @@ pub struct CatalogSyncResult {
|
||||
pub items_cached: usize,
|
||||
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||
pub libraries_failed: usize,
|
||||
/// Entries removed because the server no longer has them. Always 0 when any
|
||||
/// library failed, since a partial crawl cannot prove an item is gone.
|
||||
pub items_pruned: usize,
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -71,8 +139,6 @@ pub async fn sync_full_catalog(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
handle: String,
|
||||
) -> Result<CatalogSyncResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
@@ -80,6 +146,27 @@ pub async fn sync_full_catalog(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
run_index_pass(repo, db_service).await
|
||||
}
|
||||
|
||||
/// One full-catalog indexing pass, shared by the [`sync_full_catalog`] command
|
||||
/// and the background scheduler (DR-109) so there is exactly one implementation
|
||||
/// and one concurrency guard.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-109, DR-110
|
||||
pub(crate) async fn run_index_pass(
|
||||
repo: Arc<crate::repository::HybridRepository>,
|
||||
db_service: Arc<crate::storage::db_service::RusqliteService>,
|
||||
) -> Result<CatalogSyncResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
// One pass at a time. The command and the scheduler can both land here, and
|
||||
// two concurrent crawls would double the server load and race on the sweep.
|
||||
if INDEX_IN_PROGRESS.swap(true, Ordering::SeqCst) {
|
||||
return Err("A catalog index pass is already running".to_string());
|
||||
}
|
||||
let _guard = IndexPassGuard;
|
||||
|
||||
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||
info!(
|
||||
"[Catalog] Full sync starting across {} libraries",
|
||||
@@ -88,6 +175,10 @@ pub async fn sync_full_catalog(
|
||||
|
||||
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
// Taken before the crawl: every row the crawl writes gets a `synced_at`
|
||||
// newer than this, so anything still older afterwards is gone server-side.
|
||||
let pass_started_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let mut items_cached = 0usize;
|
||||
let mut libraries_failed = 0usize;
|
||||
|
||||
@@ -118,6 +209,35 @@ pub async fn sync_full_catalog(
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate server-side deletions — but only after a *complete* crawl.
|
||||
// `sync_full_catalog` is best-effort per library, and `items.parent_id` is
|
||||
// ON DELETE CASCADE, so sweeping when a library failed to fetch could
|
||||
// cascade a whole series away because one request timed out.
|
||||
let mut items_pruned = 0usize;
|
||||
if libraries_failed == 0 && !libraries.is_empty() {
|
||||
match repo
|
||||
.prune_stale_catalog(&pass_started_at, &include_types)
|
||||
.await
|
||||
{
|
||||
Ok(removed) => {
|
||||
items_pruned = removed;
|
||||
if removed > 0 {
|
||||
info!(
|
||||
"[Catalog] Pruned {} entries no longer on the server",
|
||||
removed
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("[Catalog] Prune of stale catalog entries failed: {:?}", e),
|
||||
}
|
||||
} else if libraries_failed > 0 {
|
||||
info!(
|
||||
"[Catalog] Skipping stale-entry prune: {} librar{} failed to sync, so the crawl is not authoritative",
|
||||
libraries_failed,
|
||||
if libraries_failed == 1 { "y" } else { "ies" }
|
||||
);
|
||||
}
|
||||
|
||||
// Record the sync time so callers can skip re-syncing too eagerly.
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let upsert = Query::with_params(
|
||||
@@ -133,16 +253,169 @@ pub async fn sync_full_catalog(
|
||||
}
|
||||
|
||||
info!(
|
||||
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
||||
items_cached, libraries_failed
|
||||
"[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
|
||||
items_cached, items_pruned, libraries_failed
|
||||
);
|
||||
|
||||
Ok(CatalogSyncResult {
|
||||
items_cached,
|
||||
libraries_failed,
|
||||
items_pruned,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an index pass is due, given when one last completed.
|
||||
///
|
||||
/// Pure so the policy is unit-testable without a clock, a server, or a database.
|
||||
/// `None` (never indexed) and an unparseable stored value both mean "due" — a
|
||||
/// corrupt timestamp should trigger a re-index, not silently freeze the catalog.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-109 | UT-115
|
||||
pub(crate) fn index_is_due(
|
||||
last_synced_at: Option<&str>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
ttl: Duration,
|
||||
) -> bool {
|
||||
let Some(raw) = last_synced_at else {
|
||||
return true;
|
||||
};
|
||||
let Ok(last) = chrono::DateTime::parse_from_rfc3339(raw) else {
|
||||
return true;
|
||||
};
|
||||
now.signed_duration_since(last.with_timezone(&chrono::Utc))
|
||||
.to_std()
|
||||
.map(|elapsed| elapsed >= ttl)
|
||||
// Negative elapsed => the stored stamp is in the future (clock skew).
|
||||
// Not due; a future stamp will age into due-ness on its own.
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read the last-sync timestamp straight from `app_settings`.
|
||||
async fn read_last_sync(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
) -> Option<String> {
|
||||
db_service
|
||||
.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Start the background catalog indexer.
|
||||
///
|
||||
/// Replaces the frontend's startup-only `syncCatalog()` call: index freshness is
|
||||
/// sync policy and belongs in Rust (see the layer assignment in
|
||||
/// docs/specs/catalog-index-search.md). Ticks every [`CATALOG_INDEX_TICK`] and
|
||||
/// runs a pass when a repository exists, the server is reachable, and the index
|
||||
/// is older than [`CATALOG_INDEX_TTL`].
|
||||
///
|
||||
/// TRACES: UR-065 | DR-109, IR-030
|
||||
pub fn spawn_catalog_indexer(app: tauri::AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Check shortly after launch, then on every tick — not tick-then-check,
|
||||
// which would leave a fresh install unindexed for a full tick.
|
||||
tokio::time::sleep(CATALOG_INDEX_FIRST_CHECK).await;
|
||||
|
||||
loop {
|
||||
if let Err(e) = maybe_run_scheduled_pass(&app).await {
|
||||
// Never fatal — a failed pass leaves the existing index in place
|
||||
// and we retry on the next tick.
|
||||
warn!("[Catalog] Scheduled index pass skipped: {}", e);
|
||||
}
|
||||
|
||||
tokio::time::sleep(CATALOG_INDEX_TICK).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One scheduler tick: check the preconditions, then index if due.
|
||||
async fn maybe_run_scheduled_pass(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
if INDEX_IN_PROGRESS.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let db_service = {
|
||||
let db = app.state::<DatabaseWrapper>();
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
if !index_is_due(
|
||||
read_last_sync(&db_service).await.as_deref(),
|
||||
chrono::Utc::now(),
|
||||
CATALOG_INDEX_TTL,
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Offline: leave the index alone. The crawl would fail every library and,
|
||||
// more importantly, a partial crawl must never reach the deletion sweep.
|
||||
{
|
||||
let monitor = app.state::<crate::commands::connectivity::ConnectivityMonitorWrapper>();
|
||||
let monitor = monitor.0.lock().await;
|
||||
if !monitor.get_status().await.is_server_reachable {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let repo = {
|
||||
let manager = app.state::<RepositoryManagerWrapper>();
|
||||
let handles = manager.0.handles();
|
||||
let Some(handle) = handles.first() else {
|
||||
// Not signed in yet.
|
||||
return Ok(());
|
||||
};
|
||||
manager.0.get(handle).ok_or("Repository not found")?
|
||||
};
|
||||
|
||||
info!("[Catalog] Index is stale; starting a scheduled pass");
|
||||
let _ = app.emit(
|
||||
CATALOG_INDEX_EVENT,
|
||||
CatalogIndexEvent {
|
||||
state: "started".to_string(),
|
||||
items_cached: 0,
|
||||
items_pruned: 0,
|
||||
libraries_failed: 0,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match run_index_pass(repo, db_service).await {
|
||||
Ok(result) => {
|
||||
let _ = app.emit(
|
||||
CATALOG_INDEX_EVENT,
|
||||
CatalogIndexEvent {
|
||||
state: "finished".to_string(),
|
||||
items_cached: result.items_cached,
|
||||
items_pruned: result.items_pruned,
|
||||
libraries_failed: result.libraries_failed,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
CATALOG_INDEX_EVENT,
|
||||
CatalogIndexEvent {
|
||||
state: "failed".to_string(),
|
||||
items_cached: 0,
|
||||
items_pruned: 0,
|
||||
libraries_failed: 0,
|
||||
error: Some(e.clone()),
|
||||
},
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
||||
/// to trigger a fresh sync.
|
||||
#[tauri::command]
|
||||
@@ -378,6 +651,43 @@ mod tests {
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// The re-index policy. Pure, so it is testable without a clock, a server or
|
||||
/// a database — which is the reason it was factored out of the scheduler.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-109 | UT-115
|
||||
#[test]
|
||||
fn test_index_is_due() {
|
||||
let ttl = Duration::from_secs(6 * 60 * 60);
|
||||
let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
// Never indexed => due. This is the first-run case.
|
||||
assert!(index_is_due(None, now, ttl));
|
||||
|
||||
// Indexed 7 hours ago => past the 6h TTL => due.
|
||||
assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
|
||||
|
||||
// Indexed 1 hour ago => fresh => not due. This is what stops the
|
||||
// scheduler re-crawling every tick.
|
||||
assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
|
||||
|
||||
// Exactly at the TTL boundary counts as due.
|
||||
assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
|
||||
|
||||
// A corrupt stored value must trigger a re-index, not freeze the
|
||||
// catalog forever behind an unparseable timestamp.
|
||||
assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
|
||||
assert!(index_is_due(Some(""), now, ttl));
|
||||
|
||||
// A timestamp in the future (clock skew, or a restored backup) is not
|
||||
// due — it ages into due-ness rather than causing a crawl every tick.
|
||||
assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
|
||||
|
||||
// Offsets other than UTC are compared as instants, not as strings.
|
||||
assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
|
||||
}
|
||||
|
||||
fn test_db() -> Arc<RusqliteService> {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
|
||||
@@ -270,6 +270,19 @@ pub async fn download_item(
|
||||
if !can_download {
|
||||
warn!("Storage limit reached. Attempting to free space...");
|
||||
|
||||
// Reclaim expired temporary entries first: they are dead weight, so
|
||||
// freeing them may avoid evicting cache that is still within its
|
||||
// life. Best-effort — a failure here just means eviction does more.
|
||||
// TRACES: UR-071 | DR-127
|
||||
match cache_arc
|
||||
.reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
|
||||
.await
|
||||
{
|
||||
Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("Expired-entry reclaim failed: {}", e),
|
||||
}
|
||||
|
||||
// Try to evict LRU items to make space
|
||||
match cache_arc
|
||||
.evict_lru_async(&db_service, &user_id, size as u64)
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Pushing favourite toggles made while the server was unreachable.
|
||||
//!
|
||||
//! Favouriting works offline: `storage_toggle_favorite` writes the local
|
||||
//! `user_data` row and sets `pending_sync = 1`. Until DR-120 nothing ever
|
||||
//! cleared that flag — the offline `mark_favorite`/`unmark_favorite` are no-ops
|
||||
//! and `syncService.queueFavorite` had no callers — so an offline toggle was
|
||||
//! silently lost.
|
||||
//!
|
||||
//! The drain lives in Rust, not the frontend, because it must run whether or
|
||||
//! not any view is mounted; a drain started by a component dies with it.
|
||||
//!
|
||||
//! TRACES: UR-069 | DR-120 | UT-103
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, info, warn};
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
|
||||
use crate::repository::types::RepoError;
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// The subset of the repository the drain needs.
|
||||
///
|
||||
/// Narrow on purpose: a test double for `MediaRepository` would be forty
|
||||
/// unimplemented methods, which is how a drain ends up untested.
|
||||
#[async_trait]
|
||||
pub trait FavoriteSink: Send + Sync {
|
||||
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: MediaRepository + ?Sized> FavoriteSink for T {
|
||||
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
|
||||
if is_favorite {
|
||||
self.mark_favorite(item_id).await
|
||||
} else {
|
||||
self.unmark_favorite(item_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A local favourite change still waiting to reach the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingFavorite {
|
||||
pub item_id: String,
|
||||
pub is_favorite: bool,
|
||||
}
|
||||
|
||||
/// Read every favourite change this user has pending.
|
||||
async fn read_pending(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<PendingFavorite>, String> {
|
||||
db.query_many(
|
||||
Query::with_params(
|
||||
"SELECT item_id, is_favorite FROM user_data \
|
||||
WHERE user_id = ? AND pending_sync = 1 AND is_favorite IS NOT NULL",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
),
|
||||
|row| {
|
||||
Ok(PendingFavorite {
|
||||
item_id: row.get::<_, String>(0)?,
|
||||
is_favorite: row.get::<_, Option<i32>>(1)?.unwrap_or(0) != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Push pending favourite changes to the server and clear their flags.
|
||||
///
|
||||
/// Returns the ids that reached the server, for the `favorites-changed` event.
|
||||
/// A row whose push fails keeps `pending_sync = 1` and is retried on the next
|
||||
/// reconnect rather than being dropped.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-103
|
||||
pub async fn drain_pending_favorites(
|
||||
db: &Arc<RusqliteService>,
|
||||
sink: &dyn FavoriteSink,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let pending = read_pending(db, user_id).await?;
|
||||
if pending.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
info!(
|
||||
"[Favorites] Pushing {} favourite change(s) queued while offline",
|
||||
pending.len()
|
||||
);
|
||||
|
||||
let mut pushed = Vec::new();
|
||||
for change in pending {
|
||||
match sink
|
||||
.push_favorite(&change.item_id, change.is_favorite)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let cleared = db
|
||||
.execute(Query::with_params(
|
||||
"UPDATE user_data SET pending_sync = 0, synced_at = ? \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String(chrono::Utc::now().to_rfc3339()),
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(change.item_id.clone()),
|
||||
],
|
||||
))
|
||||
.await;
|
||||
|
||||
match cleared {
|
||||
Ok(_) => pushed.push(change.item_id),
|
||||
// The server took it; failing to clear the flag only means
|
||||
// we push it again next time, which is harmless.
|
||||
Err(e) => warn!(
|
||||
"[Favorites] Pushed {} but could not clear pending_sync: {}",
|
||||
change.item_id, e
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Still pending — retried on the next reconnect.
|
||||
debug!(
|
||||
"[Favorites] Deferring {}, server rejected the push: {:?}",
|
||||
change.item_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(pushed)
|
||||
}
|
||||
|
||||
/// Drain on every offline→online transition.
|
||||
///
|
||||
/// Hooks the `connectivity:reconnected` event the `ConnectivityMonitor`
|
||||
/// already emits, rather than polling — reachability is derived from real
|
||||
/// traffic (DR-055) and this just reacts to it.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120
|
||||
pub fn spawn_favorites_drain(app: tauri::AppHandle) {
|
||||
let handle = app.clone();
|
||||
app.listen("connectivity:reconnected", move |_event| {
|
||||
let app = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = run_drain(&app).await {
|
||||
warn!("[Favorites] Drain skipped: {}", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let db_service: Arc<RusqliteService> = {
|
||||
let db = app.state::<crate::commands::storage::DatabaseWrapper>();
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let (repo, user_id) = {
|
||||
let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
|
||||
let handles = manager.0.handles();
|
||||
let Some(handle) = handles.first() else {
|
||||
// Not signed in — nothing to push on behalf of.
|
||||
return Ok(());
|
||||
};
|
||||
let repo = manager.0.get(handle).ok_or("Repository not found")?;
|
||||
let user_id = repo.user_id().to_string();
|
||||
(repo, user_id)
|
||||
};
|
||||
|
||||
let pushed = drain_pending_favorites(&db_service, repo.as_ref(), &user_id).await?;
|
||||
|
||||
if !pushed.is_empty() {
|
||||
let event = crate::commands::repository::FavoritesChangedEvent { item_ids: pushed };
|
||||
if let Err(e) = app.emit(crate::commands::repository::FAVORITES_CHANGED_EVENT, &event) {
|
||||
warn!("[Favorites] Failed to emit change event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Records what the server was asked to do, and can be told to fail.
|
||||
struct RecordingSink {
|
||||
calls: Mutex<Vec<(String, bool)>>,
|
||||
fail_for: Option<String>,
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_for: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failing_for(item_id: &str) -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_for: Some(item_id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<(String, bool)> {
|
||||
let mut calls = self.calls.lock().unwrap().clone();
|
||||
calls.sort();
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FavoriteSink for RecordingSink {
|
||||
async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
|
||||
if self.fail_for.as_deref() == Some(item_id) {
|
||||
return Err(RepoError::Offline);
|
||||
}
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((item_id.to_string(), is_favorite));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_db() -> Arc<RusqliteService> {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE user_data (
|
||||
user_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
is_favorite INTEGER,
|
||||
synced_at TEXT,
|
||||
pending_sync INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||
}
|
||||
|
||||
async fn seed(db: &Arc<RusqliteService>, rows: &[(&str, &str, i32, i32)]) {
|
||||
for (user, item, fav, pending) in rows {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
vec![
|
||||
QueryParam::String(user.to_string()),
|
||||
QueryParam::String(item.to_string()),
|
||||
QueryParam::Int(*fav),
|
||||
QueryParam::Int(*pending),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn pending_flag(db: &Arc<RusqliteService>, item_id: &str) -> Option<i32> {
|
||||
db.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT pending_sync FROM user_data WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
),
|
||||
|row| row.get::<_, Option<i32>>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// UT-103 — the core of the bug: a favourite toggled while offline reaches
|
||||
/// the server on reconnect, and stops being pending.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-103
|
||||
#[tokio::test]
|
||||
async fn test_drain_pushes_pending_favorites_and_clears_the_flag() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[
|
||||
("u1", "marked-offline", 1, 1),
|
||||
("u1", "unmarked-offline", 0, 1),
|
||||
// Already synced — must not be pushed again.
|
||||
("u1", "already-synced", 1, 0),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sink.calls(),
|
||||
vec![
|
||||
("marked-offline".to_string(), true),
|
||||
("unmarked-offline".to_string(), false),
|
||||
],
|
||||
"both pending changes push, with their direction preserved"
|
||||
);
|
||||
assert_eq!(pushed.len(), 2);
|
||||
assert_eq!(pending_flag(&db, "marked-offline").await, Some(0));
|
||||
assert_eq!(pending_flag(&db, "unmarked-offline").await, Some(0));
|
||||
}
|
||||
|
||||
/// A push that fails keeps its row pending, so the change is retried rather
|
||||
/// than dropped on the floor.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-103
|
||||
#[tokio::test]
|
||||
async fn test_drain_leaves_failed_pushes_pending() {
|
||||
let db = test_db();
|
||||
seed(&db, &[("u1", "ok", 1, 1), ("u1", "boom", 1, 1)]).await;
|
||||
|
||||
let sink = RecordingSink::failing_for("boom");
|
||||
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(pushed, vec!["ok".to_string()]);
|
||||
assert_eq!(pending_flag(&db, "ok").await, Some(0));
|
||||
assert_eq!(
|
||||
pending_flag(&db, "boom").await,
|
||||
Some(1),
|
||||
"a failed push must stay queued for the next reconnect"
|
||||
);
|
||||
}
|
||||
|
||||
/// Another user's queued changes are not pushed with this user's token.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-103
|
||||
#[tokio::test]
|
||||
async fn test_drain_only_touches_the_given_user() {
|
||||
let db = test_db();
|
||||
seed(&db, &[("u1", "mine", 1, 1), ("u2", "theirs", 1, 1)]).await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(pushed, vec!["mine".to_string()]);
|
||||
assert_eq!(pending_flag(&db, "theirs").await, Some(1));
|
||||
}
|
||||
|
||||
/// Nothing pending means no server calls at all — a reconnect must not
|
||||
/// generate traffic just because it happened.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-103
|
||||
#[tokio::test]
|
||||
async fn test_drain_is_a_noop_when_nothing_is_pending() {
|
||||
let db = test_db();
|
||||
seed(&db, &[("u1", "synced", 1, 0)]).await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert!(pushed.is_empty());
|
||||
assert!(sink.calls().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod connectivity;
|
||||
pub mod conversions;
|
||||
pub mod device;
|
||||
pub mod download;
|
||||
pub mod favorites;
|
||||
pub mod offline;
|
||||
pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
|
||||
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
||||
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
||||
|
||||
/// Base offset (seconds) for the active background-audio handoff.
|
||||
///
|
||||
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
|
||||
/// position, so the server makes that point the stream's zero. ExoPlayer then
|
||||
/// reports position RELATIVE to that zero. To convert back to an absolute
|
||||
/// position on exit (so the video resumes where the audio actually reached), we
|
||||
/// add this stored base to the native player's reported position.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-052
|
||||
#[derive(Default)]
|
||||
pub struct BackgroundAudioOffset(pub Mutex<f64>);
|
||||
|
||||
/// Response for player state queries
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -391,16 +379,49 @@ pub(super) async fn create_media_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if an item has a completed download
|
||||
pub(super) async fn check_for_local_download(
|
||||
db: &DatabaseWrapper,
|
||||
/// Pick the source for an audio-only handoff.
|
||||
///
|
||||
/// A downloaded file wins over the audio-only stream URL. No transcode or audio
|
||||
/// extraction is involved or wanted: the native backends already play a video
|
||||
/// container without decoding its video — the Linux MPV backend is configured
|
||||
/// with `video: no`, and ExoPlayer simply has no surface to render to when the
|
||||
/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
|
||||
/// CPU and battery, need an encoder the project does not ship, and leave a
|
||||
/// second artifact to keep in step with the first.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-128 | UT-119
|
||||
pub(super) fn background_audio_source(
|
||||
local_path: Option<String>,
|
||||
stream_url: String,
|
||||
item_id: &str,
|
||||
) -> MediaSource {
|
||||
match local_path {
|
||||
Some(path) => MediaSource::Local {
|
||||
file_path: PathBuf::from(path),
|
||||
jellyfin_item_id: Some(item_id.to_string()),
|
||||
},
|
||||
None => MediaSource::Remote {
|
||||
stream_url,
|
||||
jellyfin_item_id: item_id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the on-disk file backing a completed download, if there is one.
|
||||
///
|
||||
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
||||
/// deletion, a cleared cache directory, a restored database). Every caller wants
|
||||
/// "can I play this from disk right now", so existence is checked here rather
|
||||
/// than trusted from the row.
|
||||
///
|
||||
/// Split out from [`check_for_local_download`] so the resolution is testable
|
||||
/// without a `DatabaseWrapper`, and reusable by the video path.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-123 | UT-116
|
||||
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
|
||||
db_service: &Arc<S>,
|
||||
item_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
@@ -411,22 +432,58 @@ pub(super) async fn check_for_local_download(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Verify the file actually exists on disk
|
||||
if let Some(ref file_path) = path {
|
||||
if std::path::Path::new(file_path).exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
match path {
|
||||
Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
|
||||
Some(file_path) => {
|
||||
warn!(
|
||||
"[Player] Download entry exists in DB but file not found: {}",
|
||||
file_path
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an item has a completed download
|
||||
pub(super) async fn check_for_local_download(
|
||||
db: &DatabaseWrapper,
|
||||
item_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
resolve_local_media_path(&db_service, item_id).await
|
||||
}
|
||||
|
||||
/// The on-disk path for a downloaded item, for playback surfaces that resolve
|
||||
/// their own source rather than going through the queue.
|
||||
///
|
||||
/// The video player is the reason this exists: audio has preferred local files
|
||||
/// since queue construction, but video asks the repository for a stream URL and
|
||||
/// never consults `downloads`, so a downloaded film was still streamed — costing
|
||||
/// bandwidth that had already been spent and failing outright when offline.
|
||||
///
|
||||
/// Returns `None` when nothing is downloaded *or* the file is missing, so the
|
||||
/// caller falls back to streaming.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-123 | UT-116
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_local_media_path(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
resolve_local_media_path(&db_service, &item_id).await
|
||||
}
|
||||
|
||||
/// Re-point queued streaming items at completed local downloads.
|
||||
///
|
||||
/// Sources are resolved once when the queue is built, so downloads that finish
|
||||
@@ -586,7 +643,7 @@ pub async fn player_play_item(
|
||||
pub async fn player_enter_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
session: State<'_, MediaSessionManagerWrapper>,
|
||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item: PlayItemRequest,
|
||||
position_seconds: f64,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
@@ -595,6 +652,19 @@ pub async fn player_enter_background_audio(
|
||||
item.title, position_seconds
|
||||
);
|
||||
|
||||
// Prefer the downloaded file over the audio-only stream URL the frontend
|
||||
// resolved. Handing the native backend a local video container yields
|
||||
// audio-only playback for free — no transcode, no second artifact.
|
||||
// TRACES: UR-071 | DR-128
|
||||
let local_path = check_for_local_download(&db, &item.id).await?;
|
||||
if local_path.is_some() {
|
||||
info!(
|
||||
"player_enter_background_audio: using downloaded file for {}",
|
||||
item.id
|
||||
);
|
||||
}
|
||||
let source = background_audio_source(local_path, item.stream_url, &item.id);
|
||||
|
||||
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
||||
// create_media_item() because that hardcodes MediaType::Video; background
|
||||
// audio must be Audio so no video decode is started.
|
||||
@@ -618,10 +688,7 @@ pub async fn player_enter_background_audio(
|
||||
duration: item.duration_seconds,
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::Remote {
|
||||
stream_url: item.stream_url,
|
||||
jellyfin_item_id: item.id.clone(),
|
||||
},
|
||||
source,
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
@@ -636,17 +703,18 @@ pub async fn player_enter_background_audio(
|
||||
session_mgr.start_audio_session(media_item.clone());
|
||||
}
|
||||
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// this base to the native player's relative position to get the absolute one.
|
||||
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
|
||||
|
||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// this base to the native player's relative position to get the absolute one.
|
||||
// The controller owns it so a backend-driven advance to the next episode
|
||||
// clears it along with the stream it described.
|
||||
controller.set_background_audio_base(position_seconds);
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -677,21 +745,15 @@ pub async fn player_enter_background_audio(
|
||||
#[specta::specta]
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||
) -> Result<f64, String> {
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Read/reset the base first.
|
||||
let base = {
|
||||
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
|
||||
let b = *off;
|
||||
*off = 0.0;
|
||||
b
|
||||
};
|
||||
|
||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Zero after a backend-driven
|
||||
// episode advance, whose stream already starts at its own zero.
|
||||
let base = controller.take_background_audio_base();
|
||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||
let relative = controller.position();
|
||||
@@ -2397,6 +2459,128 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The audio-only handoff must play a downloaded file when there is one,
|
||||
/// rather than fetching an audio-only stream for media already on disk.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-128 | UT-119
|
||||
#[test]
|
||||
fn test_background_audio_source_prefers_local_file() {
|
||||
use super::background_audio_source;
|
||||
use crate::player::MediaSource;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let local = background_audio_source(
|
||||
Some("/downloads/ep1.mkv".to_string()),
|
||||
"https://server/audio-only".to_string(),
|
||||
"ep-1",
|
||||
);
|
||||
match local {
|
||||
MediaSource::Local {
|
||||
file_path,
|
||||
jellyfin_item_id,
|
||||
} => {
|
||||
assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
|
||||
// The Jellyfin id must survive so progress still syncs back.
|
||||
assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
|
||||
}
|
||||
other => panic!("expected a local source, got {:?}", other),
|
||||
}
|
||||
|
||||
let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
|
||||
match remote {
|
||||
MediaSource::Remote {
|
||||
stream_url,
|
||||
jellyfin_item_id,
|
||||
} => {
|
||||
assert_eq!(stream_url, "https://server/audio-only");
|
||||
assert_eq!(jellyfin_item_id, "ep-1");
|
||||
}
|
||||
other => panic!("expected a remote source, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// A downloaded item must resolve to its file, and a `downloads` row whose
|
||||
/// file has gone must resolve to `None` so the caller falls back to
|
||||
/// streaming instead of handing the player a path that cannot be opened.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-123 | UT-116
|
||||
#[tokio::test]
|
||||
async fn test_resolve_local_media_path() {
|
||||
use super::resolve_local_media_path;
|
||||
use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
|
||||
|
||||
// A real file on disk, so the existence check passes.
|
||||
let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
|
||||
std::fs::write(&present, b"x").unwrap();
|
||||
let present_str = present.to_string_lossy().to_string();
|
||||
|
||||
for (item, status, path) in [
|
||||
("downloaded", "completed", present_str.as_str()),
|
||||
("still-going", "downloading", present_str.as_str()),
|
||||
(
|
||||
"file-gone",
|
||||
"completed",
|
||||
"/nonexistent/jellytau/missing.mp4",
|
||||
),
|
||||
] {
|
||||
db_service
|
||||
.execute(Query::with_params(
|
||||
"INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
|
||||
vec![
|
||||
crate::storage::db_service::QueryParam::String(item.to_string()),
|
||||
crate::storage::db_service::QueryParam::String(status.to_string()),
|
||||
crate::storage::db_service::QueryParam::String(path.to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve_local_media_path(&db_service, "downloaded")
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some(present_str.as_str()),
|
||||
"a completed download with its file present must resolve"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_local_media_path(&db_service, "still-going")
|
||||
.await
|
||||
.unwrap(),
|
||||
None,
|
||||
"an in-progress download is not playable from disk"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_local_media_path(&db_service, "file-gone")
|
||||
.await
|
||||
.unwrap(),
|
||||
None,
|
||||
"a row whose file has gone must fall back to streaming, not hand over a dead path"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_local_media_path(&db_service, "never-heard-of-it")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&present);
|
||||
}
|
||||
|
||||
/// Queue items enqueued as Remote must flip to Local once a completed
|
||||
/// download exists on disk — this is what makes preloaded tracks (and
|
||||
/// offline playback after a connection drop) actually use the cache.
|
||||
|
||||
@@ -141,6 +141,8 @@ pub async fn player_play_next_episode(
|
||||
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
/// - Android JNI callback also triggers this logic directly
|
||||
///
|
||||
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_on_playback_ended(
|
||||
@@ -242,12 +244,34 @@ pub async fn player_on_playback_ended(
|
||||
});
|
||||
}
|
||||
|
||||
// Start countdown if auto_advance enabled
|
||||
// Advance if auto_advance is enabled. This is the path that actually
|
||||
// runs on Android: the JNI callback's own decision is swallowed by the
|
||||
// NewTrackLoaded end reason set at load, so it returns Stop, emits
|
||||
// PlaybackEnded, and the frontend echoes it back into this command —
|
||||
// which is where the real decision lands.
|
||||
if auto_advance {
|
||||
controller_arc
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
AutoplayDecision::ResumeStream { position } => {
|
||||
// The stream was cut short by the network, not by the media ending.
|
||||
// Re-open it where it died — no queue clearing, no PlaybackEnded, and
|
||||
// above all no leaving the player parked in ExoPlayer's STATE_ENDED,
|
||||
// where the next play intent restarts the item from 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let controller = controller_arc.lock().await;
|
||||
if let Err(e) = controller.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = controller.event_emitter() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +279,53 @@ pub async fn player_on_playback_ended(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to recover playback after a **recoverable** player error, reporting
|
||||
/// whether it was handled.
|
||||
///
|
||||
/// The frontend's error handler stops the player, which is right for a real
|
||||
/// failure and wrong for a network blip — it turned every hiccup into "playback
|
||||
/// died". This is the echo path for backends that cannot decide in-process:
|
||||
/// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
|
||||
/// its event thread has no controller to ask. It emits the error, the frontend
|
||||
/// echoes it here, and the decision stays in Rust — the same shape as
|
||||
/// `PlaybackEnded` → `player_on_playback_ended`.
|
||||
///
|
||||
/// Returns `true` when the stream was re-opened and the caller must NOT stop the
|
||||
/// player; `false` when the error is real and should be surfaced as before.
|
||||
/// Android decides inside its JNI callback and only emits errors it has already
|
||||
/// declined to recover, so this reports `false` for those without a second
|
||||
/// opinion — the shared attempt budget is spent by then either way.
|
||||
///
|
||||
/// TRACES: UR-004, UR-040 | DR-130 | UT-117
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result<bool, String> {
|
||||
let (position, delay_secs) = {
|
||||
let controller = player.0.lock().await;
|
||||
match controller.recoverable_error_resume() {
|
||||
Some(resume) => resume,
|
||||
None => return Ok(false),
|
||||
}
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[Recovery] Stream failed — re-opening at {:.1}s in {}s",
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
match controller.resume_stream_at(position).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) => {
|
||||
log::error!("[Recovery] Failed to re-open stream: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTML5 video state-report commands =====
|
||||
//
|
||||
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
||||
|
||||
@@ -15,7 +15,8 @@ use uuid::Uuid;
|
||||
use crate::domain::rank_search_results;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||
OnlineRepository,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
@@ -40,6 +41,19 @@ impl RepositoryManager {
|
||||
repos.get(handle).cloned()
|
||||
}
|
||||
|
||||
/// Handles of every live repository.
|
||||
///
|
||||
/// The background catalog indexer (DR-109) runs outside any command, so it
|
||||
/// has no handle passed in and needs to discover one. In practice there is a
|
||||
/// single signed-in repository; returning all of them avoids inventing an
|
||||
/// "active" concept the rest of the code does not have.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-109
|
||||
pub fn handles(&self) -> Vec<String> {
|
||||
let repos = self.repositories.lock_safe();
|
||||
repos.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn destroy(&self, handle: &str) {
|
||||
let mut repos = self.repositories.lock_safe();
|
||||
repos.remove(handle);
|
||||
@@ -320,6 +334,71 @@ pub async fn repository_get_next_up_episodes(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Every episode of a series, across all seasons, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and its fallback live in Rust rather than being reimplemented in the
|
||||
/// frontend (which is what it used to do).
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_episodes(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open a series.
|
||||
///
|
||||
/// "Current" is domain policy, not layout: an episode in progress, else the
|
||||
/// server's Next Up for the series, else the first unwatched episode, else the
|
||||
/// first. The third rung is what makes this work offline, where Next Up is
|
||||
/// always empty. Returns `None` only when the series has no episodes at all.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_current_episode(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Option<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Erase the viewer's watch history for an item.
|
||||
///
|
||||
/// Clears the played flag and the resume position; on a series or season the
|
||||
/// server applies it to everything inside. A series cleared this way is "never
|
||||
/// watched" again, so `repository_get_series_current_episode` returns its
|
||||
/// premiere. Requires the server — offline this fails rather than diverging
|
||||
/// local state the next sync would overwrite.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_clear_watch_history(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.clear_watch_history(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get recently played audio
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -714,6 +793,125 @@ pub async fn repository_mark_favorite(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Tauri event announcing that favourite state changed behind the UI's back —
|
||||
/// either because the server disagreed with the cache on a background refresh,
|
||||
/// or because pending offline toggles were pushed on reconnect.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120
|
||||
pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
|
||||
|
||||
/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
|
||||
/// actually flipped, so the frontend refreshes those rather than everything.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-107
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FavoritesChangedEvent {
|
||||
pub item_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Ids whose favourite state differs between what we showed and what the server
|
||||
/// has — favourited elsewhere since the cache was written, or un-favourited
|
||||
/// elsewhere.
|
||||
///
|
||||
/// Pulled out of the command so the "emit nothing when nothing changed" rule is
|
||||
/// testable: an unchanged set must leave a quiet page quiet rather than
|
||||
/// triggering a refetch on every visit.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-107
|
||||
fn changed_favorite_ids(
|
||||
cached: &std::collections::HashSet<String>,
|
||||
server: &std::collections::HashSet<String>,
|
||||
) -> Vec<String> {
|
||||
let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
|
||||
// Deterministic order so the event payload does not depend on hash seeding.
|
||||
changed.sort();
|
||||
changed
|
||||
}
|
||||
|
||||
/// Everything the viewer has favourited, across libraries, narrowed by scope.
|
||||
///
|
||||
/// Two-phase like `repository_search`: the local answer returns immediately and
|
||||
/// a background server pass emits `favorites-changed` when the server's set
|
||||
/// differs. Without the second phase a favourite marked in another client shows
|
||||
/// up only on the *second* visit to the page, since the cache-first read hands
|
||||
/// back local rows and the refresh is invisible to the frontend.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_favorites(
|
||||
app: AppHandle,
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let cache_result = repo
|
||||
.get_favorites_cache_only(scope, options.clone())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
debug!("[Favorites] Cache miss/timeout: {:?}", e);
|
||||
SearchResult {
|
||||
items: Vec::new(),
|
||||
total_record_count: 0,
|
||||
}
|
||||
});
|
||||
|
||||
// With "Show all server media" off the local answer is authoritative
|
||||
// (DR-080) — don't go behind the user's back to the server.
|
||||
if !crate::repository::offline::include_catalog_browse() {
|
||||
return Ok(cache_result);
|
||||
}
|
||||
|
||||
// Nothing cached yet — a fresh install, or a viewer whose favourites were
|
||||
// all marked on another client. Returning the empty result here paints
|
||||
// "Nothing favourited yet — tap the heart on anything you like", which is a
|
||||
// *wrong* answer, corrected a server round trip later when the background
|
||||
// refresh fires `favorites-changed`. Ask the repository for a real answer
|
||||
// instead: its `get_favorites` is exactly this read — cache first, server on
|
||||
// a miss, saving through — and it applies the same DR-080 gate.
|
||||
//
|
||||
// TRACES: UR-067 | DR-115
|
||||
if !cache_result.has_content() {
|
||||
debug!("[Favorites] Nothing cached; answering from the server");
|
||||
return repo
|
||||
.get_favorites(scope, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e));
|
||||
}
|
||||
|
||||
let repo_bg = repo.clone();
|
||||
let cached_ids: std::collections::HashSet<String> =
|
||||
cache_result.items.iter().map(|i| i.id.clone()).collect();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match repo_bg.get_favorites_server_only(scope, options).await {
|
||||
Ok(server_result) => {
|
||||
let server_ids: std::collections::HashSet<String> =
|
||||
server_result.items.iter().map(|i| i.id.clone()).collect();
|
||||
let changed = changed_favorite_ids(&cached_ids, &server_ids);
|
||||
|
||||
if !changed.is_empty() {
|
||||
let event = FavoritesChangedEvent { item_ids: changed };
|
||||
if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
|
||||
error!("[Favorites] Failed to emit change event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Favorites] Server refresh failed, keeping cached favourites: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(cache_result)
|
||||
}
|
||||
|
||||
/// Unmark an item as favorite
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -787,6 +985,44 @@ mod tests {
|
||||
assert!(manager.get("any-handle").is_none());
|
||||
}
|
||||
|
||||
fn ids(values: &[&str]) -> std::collections::HashSet<String> {
|
||||
values.iter().map(|v| v.to_string()).collect()
|
||||
}
|
||||
|
||||
/// UT-107 — the background refresh reports only what actually changed.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-107
|
||||
#[test]
|
||||
fn test_changed_favorite_ids_reports_both_directions() {
|
||||
// Favourited in another client since we cached.
|
||||
assert_eq!(
|
||||
changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
|
||||
vec!["b".to_string()]
|
||||
);
|
||||
|
||||
// Un-favourited in another client.
|
||||
assert_eq!(
|
||||
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
|
||||
vec!["b".to_string()]
|
||||
);
|
||||
|
||||
// Both at once, in a stable order.
|
||||
assert_eq!(
|
||||
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
|
||||
vec!["a".to_string(), "c".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// An unchanged set emits nothing — otherwise every visit to the page would
|
||||
/// fire an event and trigger a pointless refetch.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120 | UT-107
|
||||
#[test]
|
||||
fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
|
||||
assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
|
||||
assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_manager_wrapper_structure() {
|
||||
let manager = RepositoryManager::new();
|
||||
|
||||
@@ -47,10 +47,25 @@ pub async fn storage_save_person(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO people (
|
||||
// A real UPSERT, not INSERT OR REPLACE — `people` is now backed by the
|
||||
// `people_fts` index (migration 022), and REPLACE would orphan an index
|
||||
// entry on every re-cache: it fires no AFTER DELETE trigger without
|
||||
// `recursive_triggers`, and reassigns the rowid that `content_rowid`
|
||||
// refers to. Same defect as DR-110 fixed for `items`.
|
||||
//
|
||||
// TRACES: UR-065 | DR-110, DR-111
|
||||
"INSERT INTO people (
|
||||
id, server_id, name, overview, primary_image_tag,
|
||||
premiere_date, end_date, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
name = excluded.name,
|
||||
overview = excluded.overview,
|
||||
primary_image_tag = excluded.primary_image_tag,
|
||||
premiere_date = excluded.premiere_date,
|
||||
end_date = excluded.end_date,
|
||||
synced_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(person.id),
|
||||
QueryParam::String(person.server_id),
|
||||
|
||||
@@ -26,6 +26,12 @@ pub struct CacheConfig {
|
||||
pub storage_limit: u64,
|
||||
/// Only cache on WiFi
|
||||
pub wifi_only: bool,
|
||||
/// How long a temporary (`download_source = 'auto'`) download lives before
|
||||
/// it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
|
||||
/// the only reclaim trigger.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-127
|
||||
pub temporary_ttl_hours: u64,
|
||||
}
|
||||
|
||||
impl Default for CacheConfig {
|
||||
@@ -37,6 +43,10 @@ impl Default for CacheConfig {
|
||||
album_affinity_threshold: 3,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
// A week: long enough that re-watching over a weekend still hits
|
||||
// disk, short enough that a one-off play does not hold space
|
||||
// indefinitely.
|
||||
temporary_ttl_hours: 24 * 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +193,92 @@ impl SmartCache {
|
||||
current_size + new_size <= storage_limit
|
||||
}
|
||||
|
||||
/// Evict least recently used items to make space (async version)
|
||||
/// Reclaim temporary downloads whose life limit has passed.
|
||||
///
|
||||
/// The time-based half of the temporary tier (DR-127); [`evict_lru_async`]
|
||||
/// is the space-pressure half. A row is reclaimed by whichever fires first.
|
||||
///
|
||||
/// Scoped to `download_source = 'auto'` for the same reason eviction is: a
|
||||
/// `'user'` row is someone's own download and has no expiry. `COALESCE`
|
||||
/// guards rows predating migration 012, whose source is NULL and whose
|
||||
/// provenance must therefore be treated as the user's.
|
||||
///
|
||||
/// Expiry is normally *derived* — `completed_at` plus the configured TTL —
|
||||
/// rather than stamped at completion. That means a TTL change applies to
|
||||
/// entries already on disk instead of only to future ones, and entries
|
||||
/// predating the column expire without a backfill. `expires_at` is honoured
|
||||
/// as a per-row override when something sets it.
|
||||
///
|
||||
/// `now` is passed in rather than read from the clock so the policy is
|
||||
/// testable without sleeping. Both sides go through SQLite's `datetime()`
|
||||
/// because `completed_at` is written as `CURRENT_TIMESTAMP`
|
||||
/// (`YYYY-MM-DD HH:MM:SS`) while callers pass RFC-3339 (`…T…+00:00`) — a raw
|
||||
/// string comparison between the two formats is wrong, since `' ' < 'T'`.
|
||||
///
|
||||
/// Returns the number of entries reclaimed.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-127 | UT-120
|
||||
pub async fn reclaim_expired_async<S: DatabaseService>(
|
||||
&self,
|
||||
db_service: &Arc<S>,
|
||||
user_id: &str,
|
||||
now: &str,
|
||||
) -> Result<usize, String> {
|
||||
let ttl_hours = {
|
||||
let config = self.config.lock().map_err(|e| e.to_string())?;
|
||||
config.temporary_ttl_hours
|
||||
};
|
||||
// 0 disables time-based reclaim; space pressure remains the only trigger.
|
||||
if ttl_hours == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let expired: Vec<(i64, String)> = db_service
|
||||
.query_many(
|
||||
Query::with_params(
|
||||
"SELECT id, file_path FROM downloads
|
||||
WHERE user_id = ?
|
||||
AND COALESCE(download_source, 'user') = 'auto'
|
||||
AND status = 'completed'
|
||||
AND datetime(
|
||||
COALESCE(expires_at, datetime(completed_at, '+' || ? || ' hours'))
|
||||
) < datetime(?)",
|
||||
vec![
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(ttl_hours.to_string()),
|
||||
QueryParam::String(now.to_string()),
|
||||
],
|
||||
),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut reclaimed = 0usize;
|
||||
for (id, file_path) in expired {
|
||||
// Best-effort on the file: a missing one still needs its row gone,
|
||||
// or the sweep retries it forever.
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
db_service
|
||||
.execute(Query::with_params(
|
||||
"DELETE FROM downloads WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
reclaimed += 1;
|
||||
}
|
||||
|
||||
if reclaimed > 0 {
|
||||
info!("[SmartCache] Reclaimed {} expired entries", reclaimed);
|
||||
}
|
||||
Ok(reclaimed)
|
||||
}
|
||||
|
||||
/// Evict least recently used items to make space (async version).
|
||||
///
|
||||
/// The space-pressure half of the temporary tier; [`reclaim_expired_async`]
|
||||
/// is the time-based half.
|
||||
pub async fn evict_lru_async<S: DatabaseService>(
|
||||
&self,
|
||||
db_service: &Arc<S>,
|
||||
@@ -207,10 +302,26 @@ impl SmartCache {
|
||||
let to_free = (current_size + space_needed) - limit;
|
||||
let mut freed: u64 = 0;
|
||||
|
||||
// Get downloads ordered by last access (oldest first)
|
||||
// Only the *temporary* tier is evictable. `download_source = 'auto'` is
|
||||
// precache — the cache put it there, the cache may reclaim it. A 'user'
|
||||
// row is a download someone explicitly asked for; deleting it to make
|
||||
// room for a predictive fetch is data loss, and because the old query
|
||||
// ordered purely by `completed_at ASC` it took the oldest — typically
|
||||
// exactly the film saved for a flight.
|
||||
//
|
||||
// COALESCE, not `= 'auto'` alone: migration 012 added the column with a
|
||||
// 'user' default, but rows predating it can be NULL, and an unknown
|
||||
// provenance must be treated as the user's, never as disposable.
|
||||
//
|
||||
// Freeing less than requested is the correct outcome when only user
|
||||
// downloads remain — the caller surfaces "unable to free enough space"
|
||||
// rather than silently deleting them.
|
||||
//
|
||||
// TRACES: UR-071 | DR-126 | UT-108
|
||||
let query = Query::with_params(
|
||||
"SELECT id, file_size, file_path FROM downloads
|
||||
WHERE user_id = ? AND status = 'completed'
|
||||
AND COALESCE(download_source, 'user') = 'auto'
|
||||
ORDER BY completed_at ASC",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
@@ -304,6 +415,205 @@ mod tests {
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
/// Expiry reclaims only temporary entries that are actually past their life
|
||||
/// limit — never a user's download (which has no expiry), and never a
|
||||
/// temporary entry still within its life.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-127 | UT-120
|
||||
#[tokio::test]
|
||||
async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
|
||||
use crate::storage::db_service::{DatabaseService, RusqliteService};
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
status TEXT,
|
||||
file_size INTEGER,
|
||||
file_path TEXT,
|
||||
completed_at TEXT,
|
||||
download_source TEXT DEFAULT 'user',
|
||||
expires_at TEXT
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `completed_at` is in SQLite's CURRENT_TIMESTAMP format (space, not
|
||||
// 'T'), deliberately: the query has to compare it against an RFC-3339
|
||||
// "now" and must not do so as raw strings.
|
||||
for (path, source, completed, expires) in [
|
||||
// Completed long ago, no override => derived expiry has passed.
|
||||
("/tmp/jt-expired.mp4", "auto", "2026-01-01 00:00:00", None),
|
||||
// Completed yesterday => still inside the 7-day default TTL.
|
||||
("/tmp/jt-fresh.mp4", "auto", "2026-05-31 00:00:00", None),
|
||||
// Old, but an explicit override keeps it alive.
|
||||
(
|
||||
"/tmp/jt-override.mp4",
|
||||
"auto",
|
||||
"2026-01-01 00:00:00",
|
||||
Some("2026-12-01T00:00:00+00:00"),
|
||||
),
|
||||
// A user download must never carry an expiry, but assert the sweep
|
||||
// ignores it even if one were somehow set.
|
||||
(
|
||||
"/tmp/jt-user.mp4",
|
||||
"user",
|
||||
"2026-01-01 00:00:00",
|
||||
Some("2026-01-01T00:00:00+00:00"),
|
||||
),
|
||||
(
|
||||
"/tmp/jt-user-noexp.mp4",
|
||||
"user",
|
||||
"2026-01-01 00:00:00",
|
||||
None,
|
||||
),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (user_id, status, file_size, file_path, download_source, completed_at, expires_at)
|
||||
VALUES ('user1', 'completed', 10, ?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![path, source, completed, expires],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let conn_arc = Arc::new(Mutex::new(conn));
|
||||
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
|
||||
let cache = SmartCache::new(CacheConfig::default());
|
||||
|
||||
let reclaimed = cache
|
||||
.reclaim_expired_async(&db_service, "user1", "2026-06-01T00:00:00+00:00")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reclaimed, 1,
|
||||
"only the expired temporary entry is reclaimed"
|
||||
);
|
||||
|
||||
let surviving: Vec<String> = {
|
||||
let guard = conn_arc.lock_safe();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT file_path FROM downloads ORDER BY id")
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
rows
|
||||
};
|
||||
assert_eq!(
|
||||
surviving,
|
||||
vec![
|
||||
"/tmp/jt-fresh.mp4".to_string(),
|
||||
"/tmp/jt-override.mp4".to_string(),
|
||||
"/tmp/jt-user.mp4".to_string(),
|
||||
"/tmp/jt-user-noexp.mp4".to_string(),
|
||||
],
|
||||
"entries within their life, those with a later override, and every user download must survive"
|
||||
);
|
||||
|
||||
// TTL of 0 disables time-based reclaim entirely.
|
||||
let cache_no_ttl = SmartCache::new(CacheConfig {
|
||||
temporary_ttl_hours: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
cache_no_ttl
|
||||
.reclaim_expired_async(&db_service, "user1", "2027-01-01T00:00:00+00:00")
|
||||
.await
|
||||
.unwrap(),
|
||||
0,
|
||||
"a zero TTL leaves space pressure as the only reclaim trigger"
|
||||
);
|
||||
}
|
||||
|
||||
/// Eviction must only reclaim *temporary* (`download_source = 'auto'`)
|
||||
/// downloads — the precache tier. A download the user explicitly asked for
|
||||
/// is their file: it may be deleted by them, never by the cache making room
|
||||
/// for a predictive fetch.
|
||||
///
|
||||
/// Before the fix, `evict_lru_async` selected every completed row ordered by
|
||||
/// `completed_at ASC` with no source filter, so hitting the storage limit
|
||||
/// deleted the *oldest* download — typically the film someone downloaded for
|
||||
/// a flight — in favour of a newer auto-precached track.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-126 | UT-108
|
||||
#[tokio::test]
|
||||
async fn test_evict_lru_never_deletes_user_downloads() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
status TEXT,
|
||||
file_size INTEGER,
|
||||
file_path TEXT,
|
||||
completed_at TEXT,
|
||||
download_source TEXT DEFAULT 'user'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The user's own download is the OLDEST, so a purely time-ordered
|
||||
// eviction would take it first.
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
|
||||
VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-user.mp4', '2026-01-01', 'user')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// A newer, auto-precached item.
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
|
||||
VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-auto.mp4', '2026-06-01', 'auto')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let conn_arc = Arc::new(Mutex::new(conn));
|
||||
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
|
||||
|
||||
let cache = SmartCache::new(CacheConfig {
|
||||
storage_limit: 1000,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// 1200 bytes held against a 1000 limit: eviction must free something.
|
||||
let freed = cache
|
||||
.evict_lru_async(&db_service, "user1", 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(freed > 0, "eviction should have reclaimed the auto entry");
|
||||
|
||||
let surviving: Vec<String> = {
|
||||
let guard = conn_arc.lock_safe();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT download_source FROM downloads ORDER BY id")
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
rows
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
surviving,
|
||||
vec!["user".to_string()],
|
||||
"the user's own download must survive; only the 'auto' entry is evictable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_limit_check() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
|
||||
+28
-4
@@ -128,6 +128,8 @@ use commands::{
|
||||
player_get_sleep_timer,
|
||||
player_get_status,
|
||||
player_get_video_settings,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
player_move_in_queue,
|
||||
player_next,
|
||||
player_on_playback_ended,
|
||||
@@ -138,9 +140,9 @@ use commands::{
|
||||
player_play_next_episode,
|
||||
player_play_queue,
|
||||
player_play_tracks,
|
||||
// Preload commands
|
||||
player_preload_upcoming,
|
||||
player_previous,
|
||||
player_recover_stream,
|
||||
player_remove_from_queue,
|
||||
player_report_media_loaded,
|
||||
player_report_position,
|
||||
@@ -178,6 +180,7 @@ use commands::{
|
||||
remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// Repository commands
|
||||
repository_clear_watch_history,
|
||||
repository_create,
|
||||
repository_destroy,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
@@ -186,6 +189,7 @@ use commands::{
|
||||
repository_get_download_disk_usage,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_favorites,
|
||||
repository_get_genres,
|
||||
repository_get_image_url,
|
||||
repository_get_item,
|
||||
@@ -201,6 +205,8 @@ use commands::{
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_resume_items,
|
||||
repository_get_resume_movies,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_similar_items,
|
||||
repository_get_subtitle_url,
|
||||
repository_get_video_download_url,
|
||||
@@ -693,10 +699,12 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_cancel_autoplay_countdown,
|
||||
player_play_next_episode,
|
||||
player_on_playback_ended,
|
||||
player_recover_stream,
|
||||
player_report_state,
|
||||
player_report_position,
|
||||
player_report_media_loaded,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
player_preload_upcoming,
|
||||
player_set_cache_config,
|
||||
player_get_cache_config,
|
||||
@@ -869,6 +877,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_current_episode,
|
||||
repository_clear_watch_history,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
@@ -887,6 +898,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_image_url,
|
||||
repository_mark_favorite,
|
||||
repository_unmark_favorite,
|
||||
repository_get_favorites,
|
||||
repository_get_person,
|
||||
repository_get_items_by_person,
|
||||
repository_get_similar_items,
|
||||
@@ -1196,9 +1208,6 @@ pub fn run() {
|
||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||
app.manage(video_settings);
|
||||
|
||||
// Background-audio handoff base offset (UR-040).
|
||||
app.manage(commands::player::BackgroundAudioOffset::default());
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
@@ -1285,6 +1294,21 @@ pub fn run() {
|
||||
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
||||
app.manage(playback_reporter_wrapper);
|
||||
|
||||
// Keep the local search index fresh. Ownership of *when* to re-index
|
||||
// sits here rather than in the frontend: it is sync policy over
|
||||
// domain data, and a startup-only trigger left a long session
|
||||
// searching a stale catalog.
|
||||
// TRACES: UR-065 | DR-109, IR-030
|
||||
info!("[INIT] Starting background catalog indexer...");
|
||||
commands::catalog::spawn_catalog_indexer(app.handle().clone());
|
||||
|
||||
// Push favourite toggles made while the server was unreachable, on
|
||||
// every reconnect. In Rust rather than the frontend so it runs
|
||||
// whether or not the screen that made the change is still mounted.
|
||||
// TRACES: UR-069 | DR-120
|
||||
info!("[INIT] Starting favourites drain...");
|
||||
commands::favorites::spawn_favorites_drain(app.handle().clone());
|
||||
|
||||
info!("[INIT] Application setup completed successfully");
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -915,38 +915,37 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
}
|
||||
|
||||
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);
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||
// ExoPlayer reported ENDED because the progressive transcode's
|
||||
// connection dropped, not because the episode finished. This
|
||||
// is the arm that matters while backgrounded: it needs no
|
||||
// frontend echo, so the stream re-opens even with the webview
|
||||
// suspended — and playback never parks in STATE_ENDED, where
|
||||
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,11 +993,61 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let recoverable = recoverable != 0;
|
||||
|
||||
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||
// player error stops playback for good (the frontend's handler calls
|
||||
// player_stop); re-opening the stream where it died is the "buffer and
|
||||
// resume" this actually is. Everything else keeps reporting the error.
|
||||
if recoverable {
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
let message_str = message_str.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
// Declined here, so report it as NOT recoverable: the frontend
|
||||
// would otherwise echo it into player_recover_stream and ask
|
||||
// the same question a second time.
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||
message_str,
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear before asking the server for
|
||||
// the stream again; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: recoverable != 0,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ pub enum AutoplayDecision {
|
||||
Stop,
|
||||
/// Advance to next track in queue (for audio/movies)
|
||||
AdvanceToNext,
|
||||
/// The stream ended well short of the item's runtime — the connection
|
||||
/// dropped, not the media. Re-open the same stream at `position` instead of
|
||||
/// running any end-of-item logic (UR-040).
|
||||
ResumeStream { position: f64 },
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
current_episode: MediaItem,
|
||||
|
||||
+1001
-5
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use super::stream_end::ObservedTime;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
@@ -26,6 +27,13 @@ pub struct MpvBackend {
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
last_seek_time: Arc<AtomicU64>,
|
||||
/// Last position/duration seen while a file was loaded.
|
||||
///
|
||||
/// `time-pos` and `duration` are live properties of the *loaded* file: at
|
||||
/// EOF MPV unloads it and both stop resolving, so reading them straight
|
||||
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
|
||||
/// know where playback reached. See [`ObservedTime`].
|
||||
observed: Arc<Mutex<ObservedTime>>,
|
||||
}
|
||||
|
||||
struct InternalState {
|
||||
@@ -139,6 +147,31 @@ impl MpvBackend {
|
||||
message: format!("Failed to set initial volume: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Survive a flaky connection instead of dying on it. Without these,
|
||||
// ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
|
||||
// EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
|
||||
// in the demuxer handles the common case entirely below our level, so
|
||||
// most outages never reach the recovery in `player_recover_stream`.
|
||||
//
|
||||
// Non-fatal: these are ffmpeg-side options whose availability varies with
|
||||
// the libmpv/ffmpeg build, and losing resilience is not a reason to
|
||||
// refuse to play anything (graceful backend init, CLAUDE.md).
|
||||
mpv.set_property(
|
||||
"stream-lavf-o",
|
||||
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!(
|
||||
"[MpvBackend] Could not enable stream reconnection: {:?} — \
|
||||
playback will not survive network interruptions",
|
||||
e
|
||||
);
|
||||
});
|
||||
mpv.set_property("network-timeout", 15i64)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("[MpvBackend] Could not set network timeout: {:?}", e);
|
||||
});
|
||||
|
||||
let state = Arc::new(Mutex::new(InternalState {
|
||||
current_media: None,
|
||||
volume: 1.0,
|
||||
@@ -152,6 +185,7 @@ impl MpvBackend {
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||||
observed: Arc::new(Mutex::new(ObservedTime::default())),
|
||||
};
|
||||
|
||||
// Start event loop in background thread
|
||||
@@ -250,8 +284,22 @@ impl MpvBackend {
|
||||
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
|
||||
// Don't emit - player is shutting down
|
||||
} else if reason == MPV_END_FILE_REASON_ERROR {
|
||||
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
|
||||
// Don't emit - we should handle errors separately
|
||||
// NOT PlaybackEnded — the track did not finish, so
|
||||
// autoplay must not advance. It is an error, and it
|
||||
// has to be *said*: emitting nothing here left
|
||||
// playback halted with the UI still showing
|
||||
// "playing" and no way back. Marked recoverable so
|
||||
// the frontend echoes it into player_recover_stream,
|
||||
// which re-opens the stream where it stopped —
|
||||
// MPV's own reconnect handles shorter blips before
|
||||
// they ever get this far.
|
||||
warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: "Playback stream failed".to_string(),
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
|
||||
}
|
||||
@@ -283,6 +331,7 @@ impl MpvBackend {
|
||||
let reporter_for_position = reporter.clone();
|
||||
let throttler_for_position = throttler.clone();
|
||||
let last_seek_time_for_position = self.last_seek_time.clone();
|
||||
let observed_for_position = self.observed.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
@@ -294,6 +343,13 @@ impl MpvBackend {
|
||||
mpv_for_position.get_property::<f64>("time-pos"),
|
||||
mpv_for_position.get_property::<f64>("duration"),
|
||||
) {
|
||||
// Remember it: both properties belong to the *loaded* file and
|
||||
// stop resolving the instant MPV unloads it at EOF, which is
|
||||
// exactly when end-of-file handling asks where playback got to.
|
||||
// Recorded before the post-seek skip below so a track that ends
|
||||
// right after a seek still reports the seek target, not zero.
|
||||
observed_for_position.lock_safe().record(pos, dur);
|
||||
|
||||
// Check if we recently seeked - skip position updates briefly after seeks
|
||||
// to avoid "jumping to zero" visual glitches while MPV is seeking
|
||||
let now = SystemTime::now()
|
||||
@@ -404,6 +460,9 @@ impl PlayerBackend for MpvBackend {
|
||||
let mut state = self.state.lock_safe();
|
||||
state.current_media = Some(media.clone());
|
||||
}
|
||||
// A different file: the previous one's timestamp must not survive as this
|
||||
// one's "last observed" position.
|
||||
self.observed.lock_safe().reset();
|
||||
|
||||
// Load the media file
|
||||
self.mpv
|
||||
@@ -469,6 +528,10 @@ impl PlayerBackend for MpvBackend {
|
||||
message: format!("Failed to seek: {:?}", e),
|
||||
})?;
|
||||
|
||||
// The poll thread suppresses updates for 150ms after a seek, so without
|
||||
// this a file ending inside that window would report the pre-seek time.
|
||||
self.observed.lock_safe().record_position(position);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -491,15 +554,26 @@ impl PlayerBackend for MpvBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current position — the live `time-pos`, or the last one observed while a
|
||||
/// file was loaded.
|
||||
///
|
||||
/// The fallback is the point: `time-pos` is a property of the *loaded* file,
|
||||
/// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
|
||||
/// exactly the moment end-of-file handling asks where playback reached.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-130 | UT-121
|
||||
fn position(&self) -> f64 {
|
||||
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
|
||||
let live = self.mpv.get_property::<f64>("time-pos").ok();
|
||||
self.observed.lock_safe().position_or_last(live)
|
||||
}
|
||||
|
||||
/// Total duration — live, or the last one observed. Unloaded at EOF for the
|
||||
/// same reason as `position`.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-130 | UT-121
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.mpv
|
||||
.get_property::<f64>("duration")
|
||||
.ok()
|
||||
.filter(|d| *d > 0.0)
|
||||
let live = self.mpv.get_property::<f64>("duration").ok();
|
||||
self.observed.lock_safe().duration_or_last(live)
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Telling a *finished* stream apart from a *truncated* one.
|
||||
//!
|
||||
//! TRACES: UR-040 | DR-129 | UT-117
|
||||
//!
|
||||
//! Background audio-only playback of a video item streams a **progressive mp3
|
||||
//! transcode over plain HTTP** (see
|
||||
//! `OnlineRepository::build_audio_only_stream_url_for_video`). That response has
|
||||
//! no reliable length — a live transcode is chunked — so when the connection
|
||||
//! drops mid-episode the data source simply sees end-of-input. ExoPlayer cannot
|
||||
//! distinguish that from the real end of the media and reports
|
||||
//! `Player.STATE_ENDED`, which the app then treats as "the episode finished".
|
||||
//!
|
||||
//! The user-visible damage is not the missed advance itself. Playback parks in
|
||||
//! ExoPlayer's `STATE_ENDED`, and the next play intent from the lockscreen,
|
||||
//! notification or a Bluetooth reconnect goes through media3's
|
||||
//! `Util.handlePlayButtonAction`, which seeks an ENDED player to its default
|
||||
//! position before playing — so **the episode starts over from 0:00**. On a
|
||||
//! flaky connection that reads as "it randomly restarts the episode".
|
||||
//!
|
||||
//! The player itself has no way to know; the *duration* does. Jellyfin gives us
|
||||
//! the item's real runtime, so an end reported well short of it is a truncation,
|
||||
//! not a finish — and the right response is to re-open the stream where it died,
|
||||
//! which is the "buffer and resume" the user expects.
|
||||
|
||||
/// How far short of the item's runtime a stream may end and still count as a
|
||||
/// natural finish.
|
||||
///
|
||||
/// Sized to swallow the two sources of slack in the comparison — the position
|
||||
/// poll is up to 250 ms stale, and Jellyfin's reported runtime can disagree with
|
||||
/// the transcoded output by a second or two — while staying far below the
|
||||
/// minutes-long gap a dropped connection leaves. Erring long is the safe
|
||||
/// direction: a false "finished" is the bug we are fixing, whereas a false
|
||||
/// "truncated" only re-opens the stream for its last few seconds and then ends
|
||||
/// again normally.
|
||||
pub const TRUNCATED_STREAM_TOLERANCE_SECS: f64 = 10.0;
|
||||
|
||||
/// Consecutive resume attempts allowed at the same position before giving up.
|
||||
///
|
||||
/// A resume re-opens the same URL, so a server that is genuinely gone would
|
||||
/// otherwise end → resume → end forever. Progress past the last attempt resets
|
||||
/// the budget (see [`ResumeTracker`]), so this only bounds *stuck* retries.
|
||||
pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Position change that counts as "this is a different playback context" —
|
||||
/// either the resume made progress, or a different item is loaded.
|
||||
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
|
||||
|
||||
/// Did this end-of-stream happen far enough short of the item's runtime to be a
|
||||
/// truncation rather than a finish?
|
||||
///
|
||||
/// `position` and `duration` must be on the same timeline — for a handoff stream
|
||||
/// built with `StartTimeTicks`, that means the *absolute* position (handoff base
|
||||
/// + the player's relative position) against the item's full runtime.
|
||||
///
|
||||
/// An unknown or non-positive `duration` answers `false`: with nothing to
|
||||
/// compare against, the reported end is taken at face value (previous behaviour).
|
||||
pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) -> bool {
|
||||
let Some(duration) = duration else {
|
||||
return false;
|
||||
};
|
||||
if duration <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
position.max(0.0) + tolerance < duration
|
||||
}
|
||||
|
||||
/// Rewrite an audio-only stream URL to start at `position_seconds`.
|
||||
///
|
||||
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
||||
/// in place rather than rebuilt from the repository: every other parameter —
|
||||
/// `AudioStreamIndex` (the track the user picked in the video player),
|
||||
/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
|
||||
/// is needed to recover from a network failure.
|
||||
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
||||
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
||||
let param = format!("StartTimeTicks={}", ticks);
|
||||
|
||||
let (base, query) = match url.split_once('?') {
|
||||
Some((base, query)) => (base, query),
|
||||
// No query string at all: the URL was not built by us, but appending the
|
||||
// parameter is still the correct request to make.
|
||||
None => return format!("{}?{}", url, param),
|
||||
};
|
||||
|
||||
let mut replaced = false;
|
||||
let mut parts: Vec<String> = query
|
||||
.split('&')
|
||||
.map(|part| {
|
||||
if part.split('=').next() == Some("StartTimeTicks") {
|
||||
replaced = true;
|
||||
param.clone()
|
||||
} else {
|
||||
part.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !replaced {
|
||||
parts.push(param);
|
||||
}
|
||||
|
||||
format!("{}?{}", base, parts.join("&"))
|
||||
}
|
||||
|
||||
/// The last playback time actually observed while media was loaded.
|
||||
///
|
||||
/// Some backends expose position and duration as **live** properties of the
|
||||
/// loaded file — MPV's `time-pos` and `duration` stop resolving the moment it
|
||||
/// unloads the file at EOF. Reading them straight through means that at exactly
|
||||
/// the moment end-of-file handling wants to know where playback got to, the
|
||||
/// answer is `0.0` / unknown: the player appears to rewind to 0:00 as it ends.
|
||||
///
|
||||
/// The polling thread records here, and the accessors fall back to it, so an EOF
|
||||
/// reads as the last timestamp rather than as zero.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ObservedTime {
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
}
|
||||
|
||||
impl ObservedTime {
|
||||
/// Record a live reading. Non-positive durations are treated as unknown —
|
||||
/// that is how a backend reports "not established yet", not a real zero.
|
||||
pub fn record(&mut self, position: f64, duration: f64) {
|
||||
self.position = position.max(0.0);
|
||||
if duration > 0.0 {
|
||||
self.duration = Some(duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a position alone, e.g. straight after a seek, before the next poll.
|
||||
pub fn record_position(&mut self, position: f64) {
|
||||
self.position = position.max(0.0);
|
||||
}
|
||||
|
||||
/// Forget everything — a different file is loading, and the previous one's
|
||||
/// timestamp must not leak into it.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// The live reading if there is one, else the last observed value.
|
||||
pub fn position_or_last(&self, live: Option<f64>) -> f64 {
|
||||
live.filter(|p| *p >= 0.0).unwrap_or(self.position)
|
||||
}
|
||||
|
||||
/// The live reading if there is one, else the last observed value.
|
||||
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
|
||||
live.filter(|d| *d > 0.0).or(self.duration)
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget for consecutive resume attempts that make no progress.
|
||||
///
|
||||
/// Held by the player controller across ends of the *same* stream. Any position
|
||||
/// change larger than [`RESUME_PROGRESS_EPSILON_SECS`] — the resume played on,
|
||||
/// or a different item was loaded — is a fresh context and refills the budget.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResumeTracker {
|
||||
last_position: Option<f64>,
|
||||
attempts: u32,
|
||||
}
|
||||
|
||||
impl ResumeTracker {
|
||||
/// Record an attempt at `position`, returning its 1-based number — or `None`
|
||||
/// once the budget is spent. Callers use the number to back off: a stream
|
||||
/// that failed twice at the same spot is waiting on something slower than an
|
||||
/// immediate retry can outrun.
|
||||
pub fn allow_attempt(&mut self, position: f64) -> Option<u32> {
|
||||
let progressed = match self.last_position {
|
||||
Some(last) => (position - last).abs() > RESUME_PROGRESS_EPSILON_SECS,
|
||||
None => true,
|
||||
};
|
||||
if progressed {
|
||||
self.attempts = 0;
|
||||
}
|
||||
self.last_position = Some(position);
|
||||
self.attempts += 1;
|
||||
(self.attempts <= MAX_STALLED_RESUME_ATTEMPTS).then_some(self.attempts)
|
||||
}
|
||||
|
||||
/// Forget the budget — a new item is playing, so nothing is stuck.
|
||||
pub fn reset(&mut self) {
|
||||
self.last_position = None;
|
||||
self.attempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_end_near_duration_is_a_natural_finish() {
|
||||
// Episode runtime 25:00, stream ended at 24:56 — that is the end.
|
||||
assert!(!is_truncated_end(
|
||||
1496.0,
|
||||
Some(1500.0),
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_far_short_of_duration_is_truncated() {
|
||||
// Episode runtime 25:00, stream died at 10:00 — the connection dropped.
|
||||
assert!(is_truncated_end(
|
||||
600.0,
|
||||
Some(1500.0),
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_duration_is_taken_at_face_value() {
|
||||
// Nothing to compare against: keep the previous end-of-track behaviour
|
||||
// rather than resuming a stream that may really have finished.
|
||||
assert!(!is_truncated_end(
|
||||
600.0,
|
||||
None,
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
assert!(!is_truncated_end(
|
||||
600.0,
|
||||
Some(0.0),
|
||||
TRUNCATED_STREAM_TOLERANCE_SECS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tolerance_boundary() {
|
||||
// Exactly one tolerance short still counts as finished, so poll staleness
|
||||
// and runtime rounding never fabricate a truncation.
|
||||
assert!(!is_truncated_end(1490.0, Some(1500.0), 10.0));
|
||||
assert!(is_truncated_end(1489.0, Some(1500.0), 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_replaces_existing_ticks() {
|
||||
let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
||||
let out = with_start_time(url, 600.0);
|
||||
assert_eq!(
|
||||
out,
|
||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_appends_when_absent() {
|
||||
// The next-episode stream is built without StartTimeTicks.
|
||||
let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
|
||||
let out = with_start_time(url, 90.0);
|
||||
assert_eq!(
|
||||
out,
|
||||
"http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_preserves_selected_audio_track() {
|
||||
// The whole point of editing the URL instead of rebuilding it: the track
|
||||
// the user chose in the video player survives the resume.
|
||||
let url = "http://s/Audio/ep2/universal?AudioStreamIndex=3&MediaSourceId=src-1";
|
||||
let out = with_start_time(url, 10.0);
|
||||
assert!(out.contains("AudioStreamIndex=3"));
|
||||
assert!(out.contains("MediaSourceId=src-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_start_time_without_query() {
|
||||
assert_eq!(
|
||||
with_start_time("http://s/Audio/ep2/universal", 1.0),
|
||||
"http://s/Audio/ep2/universal?StartTimeTicks=10000000"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug: MPV unloads the file at EOF, so `time-pos` stops resolving and a
|
||||
/// straight read reports 0.0 — the position collapses to zero at precisely
|
||||
/// the moment end-of-file handling needs to know where playback reached.
|
||||
#[test]
|
||||
fn test_eof_reads_as_the_last_observed_timestamp() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(178.0, 180.0);
|
||||
|
||||
// The file is gone: both live properties fail.
|
||||
assert_eq!(observed.position_or_last(None), 178.0);
|
||||
assert_eq!(observed.duration_or_last(None), Some(180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_readings_win_while_the_file_is_loaded() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(178.0, 180.0);
|
||||
|
||||
assert_eq!(observed.position_or_last(Some(12.0)), 12.0);
|
||||
assert_eq!(observed.duration_or_last(Some(240.0)), Some(240.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unestablished_duration_is_not_recorded_as_zero() {
|
||||
let mut observed = ObservedTime::default();
|
||||
// A backend reports 0.0 for "duration not known yet", not a real zero.
|
||||
observed.record(5.0, 0.0);
|
||||
assert_eq!(observed.duration_or_last(None), None);
|
||||
assert_eq!(observed.position_or_last(None), 5.0);
|
||||
|
||||
observed.record(6.0, 180.0);
|
||||
assert_eq!(observed.duration_or_last(Some(0.0)), Some(180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_stops_the_previous_file_leaking_into_the_next() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(178.0, 180.0);
|
||||
observed.reset();
|
||||
|
||||
assert_eq!(observed.position_or_last(None), 0.0);
|
||||
assert_eq!(observed.duration_or_last(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seek_updates_the_last_position_before_the_next_poll() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(10.0, 180.0);
|
||||
observed.record_position(120.0);
|
||||
|
||||
assert_eq!(observed.position_or_last(None), 120.0);
|
||||
assert_eq!(
|
||||
observed.duration_or_last(None),
|
||||
Some(180.0),
|
||||
"seeking does not change how long the file is"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_bounds_stalled_retries() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
// Same position over and over: the stream is not recovering.
|
||||
for n in 1..=MAX_STALLED_RESUME_ATTEMPTS {
|
||||
assert_eq!(
|
||||
tracker.allow_attempt(600.0),
|
||||
Some(n),
|
||||
"attempts are numbered so callers can back off"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
tracker.allow_attempt(600.0),
|
||||
None,
|
||||
"a stream that ends at the same position every time must stop retrying"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_refills_after_progress() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
for _ in 0..MAX_STALLED_RESUME_ATTEMPTS {
|
||||
tracker.allow_attempt(600.0);
|
||||
}
|
||||
assert_eq!(tracker.allow_attempt(600.0), None);
|
||||
// The next drop happened further in — the resumes are working, so the
|
||||
// budget must not be exhausted by earlier trouble.
|
||||
assert_eq!(tracker.allow_attempt(900.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_reset() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
for _ in 0..=MAX_STALLED_RESUME_ATTEMPTS {
|
||||
tracker.allow_attempt(600.0);
|
||||
}
|
||||
tracker.reset();
|
||||
assert_eq!(tracker.allow_attempt(600.0), Some(1));
|
||||
}
|
||||
}
|
||||
@@ -41,12 +41,34 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed-in user this repository acts for.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-120
|
||||
pub fn user_id(&self) -> &str {
|
||||
self.online.user_id()
|
||||
}
|
||||
|
||||
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||
/// Delegates to online repository for connection reuse and proper auth.
|
||||
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||
self.online.download_bytes(url).await
|
||||
}
|
||||
|
||||
/// Remove catalog entries the server no longer has. Cache-only, so it goes
|
||||
/// straight to the offline repository. Callers must only invoke this after a
|
||||
/// crawl in which every library succeeded — see
|
||||
/// `OfflineRepository::prune_stale_catalog` for why a partial crawl must not
|
||||
/// sweep.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-110
|
||||
pub async fn prune_stale_catalog(
|
||||
&self,
|
||||
cutoff: &str,
|
||||
item_types: &[String],
|
||||
) -> Result<usize, RepoError> {
|
||||
self.offline.prune_stale_catalog(cutoff, item_types).await
|
||||
}
|
||||
|
||||
/// Query the JRay plugin for actors on screen at time `t`. Online-only
|
||||
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
|
||||
pub async fn get_jray_actors(
|
||||
@@ -113,6 +135,41 @@ impl HybridRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Favourites held locally, without touching the server. Backs the instant
|
||||
/// leg of the two-phase favourites read in the command layer.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115
|
||||
pub async fn get_favorites_cache_only(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Favourites straight from the server, persisted to the cache on the way
|
||||
/// through — which is also what mirrors their favourite flags into
|
||||
/// `user_data` (DR-114), so the next offline read agrees with the server.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115
|
||||
pub async fn get_favorites_server_only(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let result = self.online.get_favorites(scope, options).await?;
|
||||
if !result.items.is_empty() {
|
||||
// Favourites span libraries, so there is no single parent to file
|
||||
// them under; the parent id is only used for stub rows.
|
||||
if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
|
||||
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fetch a folder's items from the live server and persist them to the
|
||||
/// offline cache synchronously (unlike `get_items`, which saves in a
|
||||
/// fire-and-forget background task after a 100ms cache race).
|
||||
@@ -750,6 +807,11 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.unmark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.clear_watch_history(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -785,6 +847,41 @@ impl MediaRepository for HybridRepository {
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
/// TRACES: UR-067 | DR-115
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
|
||||
|
||||
// Downloads-only gate: with "Show all server media" off, an empty local
|
||||
// result means "nothing favourited is on this device" and is
|
||||
// authoritative. Falling through to the server here would re-pad the
|
||||
// page with the full favourited catalog and defeat the filter (DR-080).
|
||||
if !crate::repository::offline::include_catalog_browse() {
|
||||
if let Ok(data) = &cache_result {
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — answer from the server, *saving through* on the way back.
|
||||
// Every other read path persists what it fetches; skipping it here would
|
||||
// mean the favourites page re-queries the server on every visit and the
|
||||
// offline mirror (DR-114) never learns about favourites marked
|
||||
// elsewhere, since this path is what fills it on a fresh install.
|
||||
match self.get_favorites_server_only(scope, options).await {
|
||||
Ok(data) => Ok(data),
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
item_id: &str,
|
||||
@@ -1128,6 +1225,18 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
_scope: SearchScope,
|
||||
_options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1386,6 +1495,18 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
_scope: SearchScope,
|
||||
_options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod series_progress;
|
||||
pub mod types;
|
||||
|
||||
pub use hybrid::HybridRepository;
|
||||
@@ -211,6 +212,28 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// Unmark item as favorite
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Everything the viewer has favourited, across every library.
|
||||
///
|
||||
/// Separate from `get_items` because favourites span libraries and
|
||||
/// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
|
||||
/// frontend sends; this layer expands it to item types (DR-063) so no
|
||||
/// Jellyfin taxonomy is needed on the other side of the IPC boundary.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Erase the viewer's watch history for an item: clear its played flag and
|
||||
/// its resume position. On a container (series, season) this applies to
|
||||
/// everything inside it, so a series is returned to "never watched" and
|
||||
/// reopens on its premiere.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get person details
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
|
||||
+1156
-44
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,12 @@ pub struct OnlineRepository {
|
||||
}
|
||||
|
||||
impl OnlineRepository {
|
||||
/// The signed-in user these requests are made as. Needed by the favourites
|
||||
/// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
|
||||
pub fn user_id(&self) -> &str {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
http_client: Arc<HttpClient>,
|
||||
server_url: String,
|
||||
@@ -560,6 +566,138 @@ struct JellyfinItem {
|
||||
media_streams: Option<Vec<JellyfinMediaStream>>,
|
||||
media_sources: Option<Vec<JellyfinMediaSource>>,
|
||||
people: Option<Vec<crate::repository::types::Person>>,
|
||||
user_data: Option<JellyfinUserData>,
|
||||
}
|
||||
|
||||
/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
|
||||
///
|
||||
/// Returned on every `/Users/{uid}/Items*` response; we additionally name
|
||||
/// `UserData` in the `Fields=` list so the shape is explicit rather than
|
||||
/// dependent on the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinUserData {
|
||||
playback_position_ticks: Option<i64>,
|
||||
#[serde(rename = "Played")]
|
||||
is_played: Option<bool>,
|
||||
is_favorite: Option<bool>,
|
||||
play_count: Option<i32>,
|
||||
last_played_date: Option<String>,
|
||||
}
|
||||
|
||||
impl From<JellyfinUserData> for UserData {
|
||||
fn from(jf: JellyfinUserData) -> Self {
|
||||
UserData {
|
||||
playback_position_ticks: jf.playback_position_ticks,
|
||||
playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
|
||||
is_played: jf.is_played,
|
||||
is_favorite: jf.is_favorite,
|
||||
play_count: jf.play_count,
|
||||
last_played_date: jf.last_played_date,
|
||||
playback_context_type: None,
|
||||
playback_context_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a folder listing.
|
||||
///
|
||||
/// Extracted from `get_items` so the query it produces — in particular the
|
||||
/// favourites filter — can be asserted without standing up an HTTP server.
|
||||
///
|
||||
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
|
||||
fn build_get_items_endpoint(
|
||||
user_id: &str,
|
||||
parent_id: &str,
|
||||
options: Option<&GetItemsOptions>,
|
||||
) -> String {
|
||||
let mut endpoint = format!("/Users/{}/Items?ParentId={}", user_id, parent_id);
|
||||
|
||||
if let Some(opts) = options {
|
||||
if let Some(limit) = opts.limit {
|
||||
endpoint.push_str(&format!("&Limit={}", limit));
|
||||
}
|
||||
if let Some(start_index) = opts.start_index {
|
||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
||||
}
|
||||
if let Some(types) = &opts.include_item_types {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
}
|
||||
if let Some(sort_by) = &opts.sort_by {
|
||||
endpoint.push_str(&format!("&SortBy={}", sort_by));
|
||||
}
|
||||
if let Some(sort_order) = &opts.sort_order {
|
||||
endpoint.push_str(&format!("&SortOrder={}", sort_order));
|
||||
}
|
||||
if let Some(recursive) = opts.recursive {
|
||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
||||
}
|
||||
if let Some(genres) = &opts.genres {
|
||||
if !genres.is_empty() {
|
||||
// Genre names may contain spaces/ampersands, so percent-encode each.
|
||||
let encoded: Vec<String> = genres
|
||||
.iter()
|
||||
.map(|g| urlencoding::encode(g).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
||||
}
|
||||
}
|
||||
// TRACES: UR-067 | DR-116 | UT-104
|
||||
if opts.favorites_only == Some(true) {
|
||||
endpoint.push_str("&Filters=IsFavorite");
|
||||
}
|
||||
}
|
||||
|
||||
// Request image fields for list views (People only needed in get_item
|
||||
// detail view). Genres is needed so cached items carry their genres,
|
||||
// which lets the offline store derive genre lists + per-genre counts.
|
||||
endpoint
|
||||
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a favourites listing.
|
||||
///
|
||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
||||
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
|
||||
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
|
||||
/// union, which would silently drop every type nobody enumerated (see
|
||||
/// `SearchScope::item_types`).
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
||||
fn build_favorites_endpoint(
|
||||
user_id: &str,
|
||||
scope: SearchScope,
|
||||
options: Option<&GetItemsOptions>,
|
||||
) -> String {
|
||||
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
|
||||
|
||||
if let Some(types) = scope.item_types() {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
}
|
||||
|
||||
// Jellyfin has no "date favourited", so name order is the only stable sort
|
||||
// available; callers may still override it.
|
||||
let sort_by = options
|
||||
.and_then(|o| o.sort_by.as_deref())
|
||||
.unwrap_or("SortName");
|
||||
let sort_order = options
|
||||
.and_then(|o| o.sort_order.as_deref())
|
||||
.unwrap_or("Ascending");
|
||||
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
|
||||
|
||||
if let Some(limit) = options.and_then(|o| o.limit) {
|
||||
endpoint.push_str(&format!("&Limit={}", limit));
|
||||
}
|
||||
if let Some(start_index) = options.and_then(|o| o.start_index) {
|
||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
||||
}
|
||||
|
||||
endpoint
|
||||
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
||||
endpoint
|
||||
}
|
||||
|
||||
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
|
||||
@@ -653,7 +791,9 @@ impl JellyfinItem {
|
||||
series_name: self.series_name,
|
||||
season_id: self.season_id,
|
||||
season_name: self.season_name,
|
||||
user_data: None, // User data not included in basic item responses
|
||||
// Favourite/played/resume state as the server sees it. TRACES:
|
||||
// UR-069 | DR-113, JA-034
|
||||
user_data: self.user_data.map(UserData::from),
|
||||
media_streams: self.media_streams.map(|streams| {
|
||||
streams
|
||||
.into_iter()
|
||||
@@ -728,43 +868,7 @@ impl MediaRepository for OnlineRepository {
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let mut endpoint = format!("/Users/{}/Items?ParentId={}", self.user_id, parent_id);
|
||||
|
||||
if let Some(opts) = options {
|
||||
if let Some(limit) = opts.limit {
|
||||
endpoint.push_str(&format!("&Limit={}", limit));
|
||||
}
|
||||
if let Some(start_index) = opts.start_index {
|
||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
||||
}
|
||||
if let Some(types) = opts.include_item_types {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
}
|
||||
if let Some(sort_by) = opts.sort_by {
|
||||
endpoint.push_str(&format!("&SortBy={}", sort_by));
|
||||
}
|
||||
if let Some(sort_order) = opts.sort_order {
|
||||
endpoint.push_str(&format!("&SortOrder={}", sort_order));
|
||||
}
|
||||
if let Some(recursive) = opts.recursive {
|
||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
||||
}
|
||||
if let Some(genres) = opts.genres {
|
||||
if !genres.is_empty() {
|
||||
// Genre names may contain spaces/ampersands, so percent-encode each.
|
||||
let encoded: Vec<String> = genres
|
||||
.iter()
|
||||
.map(|g| urlencoding::encode(g).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request image fields for list views (People only needed in get_item
|
||||
// detail view). Genres is needed so cached items carry their genres,
|
||||
// which lets the offline store derive genre lists + per-genre counts.
|
||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
|
||||
let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
|
||||
@@ -779,7 +883,7 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
|
||||
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
let media_item = item.to_media_item(self.user_id.clone());
|
||||
@@ -794,7 +898,7 @@ impl MediaRepository for OnlineRepository {
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, parent_id, limit_str
|
||||
);
|
||||
|
||||
@@ -812,7 +916,7 @@ impl MediaRepository for OnlineRepository {
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, limit_str
|
||||
);
|
||||
|
||||
@@ -835,7 +939,7 @@ impl MediaRepository for OnlineRepository {
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, limit_str
|
||||
);
|
||||
|
||||
@@ -859,7 +963,7 @@ impl MediaRepository for OnlineRepository {
|
||||
// Fetch more items to account for grouping reducing the count
|
||||
let fetch_limit = limit_val * 3;
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, fetch_limit
|
||||
);
|
||||
|
||||
@@ -993,7 +1097,7 @@ impl MediaRepository for OnlineRepository {
|
||||
// Filters=IsPlayed keeps only albums the user has actually listened to,
|
||||
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, limit_val
|
||||
);
|
||||
|
||||
@@ -1012,7 +1116,7 @@ impl MediaRepository for OnlineRepository {
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, limit_str
|
||||
);
|
||||
|
||||
@@ -1113,7 +1217,9 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
// Request image fields for list views (plus Genres so cached items
|
||||
// carry genres for offline genre lists/counts).
|
||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
|
||||
endpoint.push_str(
|
||||
"&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
|
||||
);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(SearchResult {
|
||||
@@ -1654,6 +1760,25 @@ impl MediaRepository for OnlineRepository {
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
||||
async fn get_favorites(
|
||||
&self,
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
|
||||
Ok(SearchResult {
|
||||
items: response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||
.collect(),
|
||||
total_record_count: response.total_record_count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
@@ -1691,6 +1816,48 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
|
||||
/// unplayed", which also zeroes the resume position. On a folder (series,
|
||||
/// season) the server applies it recursively to the children.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106, JA-033
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
self.report_outcome(&result).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
@@ -1705,7 +1872,7 @@ impl MediaRepository for OnlineRepository {
|
||||
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
||||
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, person_id, limit
|
||||
);
|
||||
|
||||
@@ -1739,7 +1906,7 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
// Try the /Similar endpoint which works for most items
|
||||
let endpoint = format!(
|
||||
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
item_id, self.user_id, limit_str
|
||||
);
|
||||
|
||||
@@ -2327,6 +2494,140 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-100 — the favourites endpoint asks the server for favourites, scoped.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
||||
#[test]
|
||||
fn test_build_favorites_endpoint_scopes_and_filters() {
|
||||
let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
|
||||
assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
|
||||
assert!(movies.contains("&IncludeItemTypes=Movie"));
|
||||
// Jellyfin has no favourite timestamp, so name order is the default.
|
||||
assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
|
||||
// Hearts must render on the returned cards.
|
||||
assert!(movies.contains("UserData"));
|
||||
|
||||
// Tv covers both the show and any individually favourited episode.
|
||||
let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
|
||||
assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
|
||||
|
||||
let music = build_favorites_endpoint("u1", SearchScope::Music, None);
|
||||
assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
|
||||
}
|
||||
|
||||
/// `All` must omit the type filter entirely rather than send a union, which
|
||||
/// would silently drop every type nobody enumerated.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115 | UT-100
|
||||
#[test]
|
||||
fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
|
||||
let all = build_favorites_endpoint("u1", SearchScope::All, None);
|
||||
assert!(!all.contains("IncludeItemTypes"));
|
||||
}
|
||||
|
||||
/// Paging and an explicit sort still reach the server.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115 | UT-100
|
||||
#[test]
|
||||
fn test_build_favorites_endpoint_honours_paging_and_sort() {
|
||||
let endpoint = build_favorites_endpoint(
|
||||
"u1",
|
||||
SearchScope::All,
|
||||
Some(&GetItemsOptions {
|
||||
limit: Some(20),
|
||||
start_index: Some(40),
|
||||
sort_by: Some("Random".to_string()),
|
||||
sort_order: Some("Descending".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(endpoint.contains("&Limit=20"));
|
||||
assert!(endpoint.contains("&StartIndex=40"));
|
||||
assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
|
||||
}
|
||||
|
||||
/// UT-104 — the in-library favourites toggle reaches the server as
|
||||
/// `Filters=IsFavorite`, and is absent unless asked for.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-116 | UT-104
|
||||
#[test]
|
||||
fn test_get_items_endpoint_applies_favorites_only() {
|
||||
let plain = build_get_items_endpoint("u1", "lib-1", None);
|
||||
assert!(!plain.contains("Filters=IsFavorite"));
|
||||
|
||||
let filtered = build_get_items_endpoint(
|
||||
"u1",
|
||||
"lib-1",
|
||||
Some(&GetItemsOptions {
|
||||
favorites_only: Some(true),
|
||||
include_item_types: Some(vec!["Movie".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(filtered.contains("&Filters=IsFavorite"));
|
||||
// Composes with the filters already there rather than replacing them.
|
||||
assert!(filtered.contains("&IncludeItemTypes=Movie"));
|
||||
assert!(filtered.contains("ParentId=lib-1"));
|
||||
|
||||
// Explicitly false is not a request to filter.
|
||||
let off = build_get_items_endpoint(
|
||||
"u1",
|
||||
"lib-1",
|
||||
Some(&GetItemsOptions {
|
||||
favorites_only: Some(false),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(!off.contains("Filters=IsFavorite"));
|
||||
}
|
||||
|
||||
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
|
||||
///
|
||||
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
||||
/// the mini player could know an item was favourited.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
|
||||
#[test]
|
||||
fn test_jellyfin_item_maps_user_data_favorite() {
|
||||
let json = r#"{
|
||||
"Id": "movie123",
|
||||
"Name": "Test Movie",
|
||||
"Type": "Movie",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 6000000000,
|
||||
"Played": false,
|
||||
"IsFavorite": true,
|
||||
"PlayCount": 2,
|
||||
"LastPlayedDate": "2026-08-01T12:00:00Z"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
|
||||
let media = item.to_media_item("server1".to_string());
|
||||
|
||||
let user_data = media.user_data.expect("user data should be mapped");
|
||||
assert_eq!(user_data.is_favorite, Some(true));
|
||||
assert_eq!(user_data.is_played, Some(false));
|
||||
assert_eq!(user_data.play_count, Some(2));
|
||||
assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
|
||||
// Ticks are converted for the frontend, which never divides them itself.
|
||||
assert_eq!(user_data.playback_position_ms, Some(600_000));
|
||||
}
|
||||
|
||||
/// An item without `UserData` still maps — the field is optional, and every
|
||||
/// non-user-scoped endpoint omits it.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-113 | UT-099
|
||||
#[test]
|
||||
fn test_jellyfin_item_without_user_data_maps_to_none() {
|
||||
let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
|
||||
|
||||
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
|
||||
let media = item.to_media_item("server1".to_string());
|
||||
|
||||
assert!(media.user_data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jellyfin_item_deserialize_with_artist_items() {
|
||||
// Test that ArtistItems with PascalCase fields deserialize correctly
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Where a viewer is in a TV series.
|
||||
//!
|
||||
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
|
||||
//! means ("in progress", "played") and what Jellyfin's season numbering means
|
||||
//! (season 0 is specials). The frontend asks for *the* current episode and
|
||||
//! renders it; it does not get to decide what "current" means.
|
||||
//!
|
||||
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
|
||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||
//! can be unit-tested without standing up a repository.
|
||||
//!
|
||||
//! TRACES: UR-062 | DR-101
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
/// Jellyfin files specials under season 0.
|
||||
const SPECIALS_SEASON: i32 = 0;
|
||||
|
||||
/// Below this fraction watched, a position is a false start rather than
|
||||
/// progress — the same threshold the resume dialog uses.
|
||||
const MIN_PROGRESS_FRACTION: f64 = 0.01;
|
||||
|
||||
/// Above this fraction watched, an episode is effectively finished; resuming it
|
||||
/// would drop the viewer into the closing credits.
|
||||
const MAX_PROGRESS_FRACTION: f64 = 0.95;
|
||||
|
||||
/// Sort key for a season number. Specials sort *after* every numbered season:
|
||||
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
|
||||
/// not lead just because `0 < 1`.
|
||||
fn season_rank(season: Option<i32>) -> i64 {
|
||||
match season {
|
||||
Some(SPECIALS_SEASON) => i64::MAX,
|
||||
Some(n) => n as i64,
|
||||
None => i64::MAX - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Order episodes as the series is watched: season ascending, then episode,
|
||||
/// specials last.
|
||||
pub fn sort_series_order(episodes: &mut [MediaItem]) {
|
||||
episodes.sort_by(|a, b| {
|
||||
season_rank(a.parent_index_number)
|
||||
.cmp(&season_rank(b.parent_index_number))
|
||||
.then(
|
||||
a.index_number
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.index_number.unwrap_or(0)),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Is this episode genuinely part-watched (not a false start, not finished)?
|
||||
fn is_in_progress(item: &MediaItem) -> bool {
|
||||
let Some(user_data) = item.user_data.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if user_data.is_played.unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let position_ms = user_data
|
||||
.playback_position_ms
|
||||
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||
.unwrap_or(0);
|
||||
if position_ms <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Without a duration we cannot tell "2 minutes in" from "2 minutes left",
|
||||
// so any recorded position counts as progress.
|
||||
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let fraction = position_ms as f64 / duration_ms as f64;
|
||||
(MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
|
||||
}
|
||||
|
||||
fn is_played(item: &MediaItem) -> bool {
|
||||
item.user_data
|
||||
.as_ref()
|
||||
.and_then(|u| u.is_played)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
item.series_id.as_deref() == Some(series_id)
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open `series_id`.
|
||||
///
|
||||
/// Order of preference, and why:
|
||||
///
|
||||
/// 1. **An episode in progress.** That is literally where playback stopped;
|
||||
/// Next Up would skip past it. On a tie the earliest in series order wins, so
|
||||
/// a viewer who dipped into a later episode still returns to the one they are
|
||||
/// working through.
|
||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||
/// we do not cache locally.
|
||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
||||
/// this rung the whole feature would be online-only.
|
||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||
/// rather than on nothing.
|
||||
///
|
||||
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
|
||||
/// (the season fan-out can miss an id the server returns), but only when they
|
||||
/// belong to this series.
|
||||
pub fn pick_current_episode(
|
||||
series_id: &str,
|
||||
episodes: &[MediaItem],
|
||||
next_up: &[MediaItem],
|
||||
resume: &[MediaItem],
|
||||
) -> Option<MediaItem> {
|
||||
// 1. In progress — prefer a match inside the ordered episode list so the
|
||||
// "earliest in series order" tie-break is meaningful; fall back to the
|
||||
// resume feed for an episode the fan-out missed.
|
||||
if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
if let Some(found) = resume
|
||||
.iter()
|
||||
.find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
|
||||
{
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 2. Next Up for this series.
|
||||
if let Some(found) = next_up
|
||||
.iter()
|
||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
||||
{
|
||||
// Prefer the copy from `episodes` when we have one: it carries the
|
||||
// user-data and images the list already fetched.
|
||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||
return Some(matched.unwrap_or(found).clone());
|
||||
}
|
||||
|
||||
// 3. First unwatched in series order.
|
||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 4. First episode — a fully-watched series reopens at the start.
|
||||
episodes.first().cloned()
|
||||
}
|
||||
|
||||
/// Every episode of a series, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders, except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and the fallback live here rather than in the frontend.
|
||||
pub async fn fetch_series_episodes(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let children = repo.get_items(series_id, list_options()).await?;
|
||||
|
||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
||||
// One failing season must not blank the whole show.
|
||||
match repo.get_items(&season.id, list_options()).await {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[series] season {} of {} failed to load: {:?}",
|
||||
season.id,
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flat series: the children *are* the episodes.
|
||||
if episodes.is_empty() {
|
||||
episodes.extend(children.items.into_iter().filter(is_episode));
|
||||
}
|
||||
|
||||
sort_series_order(&mut episodes);
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
/// `pick_current_episode` has fallbacks for exactly that.
|
||||
pub async fn resolve_current_episode(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Option<MediaItem>, RepoError> {
|
||||
let episodes = fetch_series_episodes(repo, series_id).await?;
|
||||
|
||||
let next_up = repo
|
||||
.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resume = repo
|
||||
.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(pick_current_episode(
|
||||
series_id, &episodes, &next_up, &resume,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_options() -> Option<GetItemsOptions> {
|
||||
Some(GetItemsOptions {
|
||||
limit: Some(500),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_season(item: &MediaItem) -> bool {
|
||||
item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
|
||||
}
|
||||
|
||||
fn is_episode(item: &MediaItem) -> bool {
|
||||
item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::UserData;
|
||||
|
||||
const SERIES: &str = "series-1";
|
||||
|
||||
fn episode(id: &str, season: i32, number: i32) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("S{season}E{number}"),
|
||||
item_type: "Episode".to_string(),
|
||||
series_id: Some(SERIES.to_string()),
|
||||
parent_index_number: Some(season),
|
||||
index_number: Some(number),
|
||||
duration_ms: Some(1_000_000),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
|
||||
let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some((duration * fraction) as i64),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn season(n: i32, count: i32) -> Vec<MediaItem> {
|
||||
(1..=count)
|
||||
.map(|i| episode(&format!("s{n}e{i}"), n, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_season_then_episode() {
|
||||
let mut eps = vec![
|
||||
episode("b", 2, 1),
|
||||
episode("d", 1, 10),
|
||||
episode("a", 1, 2),
|
||||
episode("c", 2, 2),
|
||||
];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "d", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_specials_after_numbered_seasons() {
|
||||
let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["premiere", "special"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_in_progress_episode_over_next_up() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.4);
|
||||
// The server would send us past it; the half-watched episode wins.
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_earliest_in_progress_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[1] = in_progress(eps[1].clone(), 0.3);
|
||||
eps[3] = in_progress(eps[3].clone(), 0.5);
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_a_false_start_and_a_finished_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
|
||||
eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
|
||||
|
||||
// Neither counts as progress, so Next Up decides.
|
||||
let next_up = vec![episode("s1e4", 1, 4)];
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_next_up_when_nothing_is_in_progress() {
|
||||
let eps = season(1, 5);
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_up_from_another_series_is_ignored() {
|
||||
let eps = season(1, 3);
|
||||
let mut foreign = episode("other-show-ep", 1, 1);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
/// The offline path: `OfflineRepository::get_next_up_episodes` returns an
|
||||
/// empty vec, so the first unwatched episode has to carry the feature.
|
||||
#[test]
|
||||
fn falls_back_to_first_unwatched_when_next_up_is_empty() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(4) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(3) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_watched_series_opens_on_its_premiere() {
|
||||
let eps = [season(2, 3), season(1, 3)].concat();
|
||||
let mut ordered = eps.clone();
|
||||
sort_series_order(&mut ordered);
|
||||
|
||||
let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_watched_series_reopens_at_the_start() {
|
||||
let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_a_resume_entry_missing_from_the_episode_list() {
|
||||
// Season fan-out returned nothing usable, but the resume feed knows.
|
||||
let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
|
||||
assert_eq!(current.id, "s3e7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_entries_from_other_series_are_ignored() {
|
||||
let mut foreign = in_progress(episode("other", 1, 1), 0.5);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_with_no_episodes_has_no_current_episode() {
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_episode_without_a_duration_still_counts_as_in_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.duration_ms = None;
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some(120_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tick_positions_still_register_as_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
// 400_000 ms expressed in Jellyfin ticks, no ms field.
|
||||
playback_position_ticks: Some(400_000 * 10_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub struct Library {
|
||||
}
|
||||
|
||||
/// User-specific data for an item (playback state, favorites, etc.)
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
@@ -292,6 +292,12 @@ pub struct GetItemsOptions {
|
||||
pub fields: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genres: Option<Vec<String>>,
|
||||
/// Restrict the listing to favourited items. Backs the per-library
|
||||
/// favourites toggle; composes with every other filter here.
|
||||
///
|
||||
/// TRACES: UR-067 | DR-116 | UT-104
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub favorites_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// An opaque search scope the frontend selects; Rust owns what it *means*.
|
||||
|
||||
@@ -25,6 +25,9 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("018_items_is_folder", MIGRATION_018),
|
||||
("019_genres_cache", MIGRATION_019),
|
||||
("020_items_season_index", MIGRATION_020),
|
||||
("021_rebuild_items_fts", MIGRATION_021),
|
||||
("022_people_fts", MIGRATION_022),
|
||||
("023_downloads_expiry", MIGRATION_023),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -728,3 +731,91 @@ CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
|
||||
const MIGRATION_020: &str = r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
|
||||
"#;
|
||||
|
||||
/// Discard and rebuild the FTS index from the `items` table.
|
||||
///
|
||||
/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
|
||||
/// deletes the conflicting row and inserts a new one, but SQLite only fires
|
||||
/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
|
||||
/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
|
||||
/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
|
||||
/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
|
||||
/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
|
||||
/// therefore left another duplicate behind, and existing installs carry one
|
||||
/// stale entry per item per sync since the database was created.
|
||||
///
|
||||
/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
|
||||
/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
|
||||
/// permanently, and it becomes a *correctness* problem the moment rowids are
|
||||
/// freed and reused: a new item landing on a freed rowid inherits the orphan's
|
||||
/// index entry and matches queries for the deleted item's title. The DR-110
|
||||
/// deletion sweep frees rowids, so this rebuild must run before it.
|
||||
///
|
||||
/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
|
||||
/// repopulates it from the external content table.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-110
|
||||
const MIGRATION_021: &str = r#"
|
||||
INSERT INTO items_fts(items_fts) VALUES('rebuild');
|
||||
"#;
|
||||
|
||||
/// Full-text index over `people`, mirroring `items_fts`.
|
||||
///
|
||||
/// People live in their own table (migration 009) rather than in `items`, and
|
||||
/// had no FTS index at all — so the People group UR-060 requires could only ever
|
||||
/// be filled by the server leg of search. With the local index now answering
|
||||
/// first, an actor's name has to be findable offline too.
|
||||
///
|
||||
/// TRACES: UR-065, UR-060 | DR-111
|
||||
const MIGRATION_022: &str = r#"
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
|
||||
name,
|
||||
overview,
|
||||
content='people',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
|
||||
INSERT INTO people_fts(rowid, name, overview)
|
||||
VALUES (new.rowid, new.name, new.overview);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
|
||||
INSERT INTO people_fts(people_fts, rowid, name, overview)
|
||||
VALUES('delete', old.rowid, old.name, old.overview);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
|
||||
INSERT INTO people_fts(people_fts, rowid, name, overview)
|
||||
VALUES('delete', old.rowid, old.name, old.overview);
|
||||
INSERT INTO people_fts(rowid, name, overview)
|
||||
VALUES (new.rowid, new.name, new.overview);
|
||||
END;
|
||||
|
||||
-- Backfill for rows cached before this index existed.
|
||||
INSERT INTO people_fts(people_fts) VALUES('rebuild');
|
||||
"#;
|
||||
|
||||
/// Give temporary downloads a life limit.
|
||||
///
|
||||
/// A cache entry is not a different kind of object from a download — it is a
|
||||
/// download with a shorter life. Modelling it as one `downloads` row with an
|
||||
/// expiry (rather than a parallel cache store) means there is a single storage
|
||||
/// accounting, a single eviction path, and no way for a cache and a download
|
||||
/// library to disagree about what is on disk.
|
||||
///
|
||||
/// `expires_at` is NULL for permanent rows, which is every row that exists
|
||||
/// today: `download_source` defaults to `'user'`, and a user's own download
|
||||
/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
|
||||
/// whichever comes first — the expiry passing, or LRU eviction under space
|
||||
/// pressure (DR-126).
|
||||
///
|
||||
/// TRACES: UR-071 | DR-127
|
||||
const MIGRATION_023: &str = r#"
|
||||
ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
||||
|
||||
-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
|
||||
-- stays cheap as the cache tier grows.
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||
ON downloads(download_source, expires_at);
|
||||
"#;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.2.1",
|
||||
"version": "0.4.0",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+31
-5
@@ -14,6 +14,37 @@
|
||||
--color-surface-hover: #252525;
|
||||
}
|
||||
|
||||
/* Safe-area insets — the single source of edge padding for the whole app.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
*
|
||||
* Two independent sources have to be folded together:
|
||||
*
|
||||
* - `env(safe-area-inset-*)` — iOS/desktop, and the *display cutout* on
|
||||
* Android. Requires `viewport-fit=cover` (see src/app.html) or it is 0px.
|
||||
* - `var(--jt-inset-*)` — real Android `WindowInsets` (status bar, navigation/
|
||||
* gesture bar, cutout) pushed in from Kotlin, because Android WebView never
|
||||
* reports the *system bars* through `env()`. See WindowInsetsBridge.kt and
|
||||
* $lib/utils/safeArea.ts.
|
||||
*
|
||||
* `max()` takes whichever is real on this platform; both are 0 on desktop.
|
||||
* Consumers must use `--safe-*` and never `env()` directly — a bare `env()` is
|
||||
* silently 0 for the Android system bars, which is what put the bottom nav
|
||||
* under the navigation bar on 3-button-nav devices.
|
||||
*
|
||||
* Applied at the edges that own them: the app shell (top/left/right) and
|
||||
* BottomUi (bottom, so its surface colour extends behind the gesture bar).
|
||||
* Deliberately NOT applied to `body` — the shell is `h-screen`, and body
|
||||
* padding would push 100vh past the viewport, and `position: fixed` overlays
|
||||
* (the video/audio players) ignore body padding anyway.
|
||||
*/
|
||||
:root {
|
||||
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
|
||||
--safe-right: max(env(safe-area-inset-right, 0px), var(--jt-inset-right, 0px));
|
||||
--safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--jt-inset-bottom, 0px));
|
||||
--safe-left: max(env(safe-area-inset-left, 0px), var(--jt-inset-left, 0px));
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
html, body {
|
||||
@apply h-full;
|
||||
@@ -23,9 +54,4 @@ html, body {
|
||||
body {
|
||||
@apply text-white antialiased;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* Handle safe areas for mobile devices (status bar, notches, etc.) */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
+10
-1
@@ -3,7 +3,16 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--
|
||||
`viewport-fit=cover` is REQUIRED: without it every `env(safe-area-inset-*)`
|
||||
resolves to 0px, so the safe-area padding in app.css/BottomUi is a no-op
|
||||
and the bottom nav renders under the Android navigation bar. See
|
||||
$lib/utils/safeArea.ts for the other half (native WindowInsets → CSS vars).
|
||||
-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>JellyTau</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
+119
-3
@@ -237,10 +237,35 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
|
||||
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
* - Android JNI callback also triggers this logic directly
|
||||
*
|
||||
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
|
||||
*/
|
||||
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
||||
},
|
||||
/**
|
||||
* Try to recover playback after a **recoverable** player error, reporting
|
||||
* whether it was handled.
|
||||
*
|
||||
* The frontend's error handler stops the player, which is right for a real
|
||||
* failure and wrong for a network blip — it turned every hiccup into "playback
|
||||
* died". This is the echo path for backends that cannot decide in-process:
|
||||
* MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
|
||||
* its event thread has no controller to ask. It emits the error, the frontend
|
||||
* echoes it here, and the decision stays in Rust — the same shape as
|
||||
* `PlaybackEnded` → `player_on_playback_ended`.
|
||||
*
|
||||
* Returns `true` when the stream was re-opened and the caller must NOT stop the
|
||||
* player; `false` when the error is real and should be surfaced as before.
|
||||
* Android decides inside its JNI callback and only emits errors it has already
|
||||
* declined to recover, so this reports `false` for those without a second
|
||||
* opinion — the shared attempt budget is spent by then either way.
|
||||
*
|
||||
* TRACES: UR-004, UR-040 | DR-130 | UT-117
|
||||
*/
|
||||
async playerRecoverStream() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("player_recover_stream");
|
||||
},
|
||||
/**
|
||||
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
|
||||
*/
|
||||
@@ -260,6 +285,23 @@ async playerReportPosition(position: number, duration: number) : Promise<null> {
|
||||
async playerReportMediaLoaded(duration: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("player_report_media_loaded", { duration });
|
||||
},
|
||||
/**
|
||||
* The on-disk path for a downloaded item, for playback surfaces that resolve
|
||||
* their own source rather than going through the queue.
|
||||
*
|
||||
* The video player is the reason this exists: audio has preferred local files
|
||||
* since queue construction, but video asks the repository for a stream URL and
|
||||
* never consults `downloads`, so a downloaded film was still streamed — costing
|
||||
* bandwidth that had already been spent and failing outright when offline.
|
||||
*
|
||||
* Returns `None` when nothing is downloaded *or* the file is missing, so the
|
||||
* caller falls back to streaming.
|
||||
*
|
||||
* TRACES: UR-071 | DR-123 | UT-116
|
||||
*/
|
||||
async playerLocalMediaPath(itemId: string) : Promise<string | null> {
|
||||
return await TAURI_INVOKE("player_local_media_path", { itemId });
|
||||
},
|
||||
/**
|
||||
* Preload upcoming tracks from the queue
|
||||
* This queues background downloads for the next N tracks that aren't already downloaded
|
||||
@@ -1265,6 +1307,46 @@ async repositoryGetResumeItems(handle: string, parentId: string | null, limit: n
|
||||
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
|
||||
},
|
||||
/**
|
||||
* Every episode of a series, across all seasons, in series order.
|
||||
*
|
||||
* Jellyfin hangs episodes off season folders — except for "flat" series whose
|
||||
* children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
* fan-out and its fallback live in Rust rather than being reimplemented in the
|
||||
* frontend (which is what it used to do).
|
||||
*
|
||||
* TRACES: UR-062 | DR-101
|
||||
*/
|
||||
async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_series_episodes", { handle, seriesId });
|
||||
},
|
||||
/**
|
||||
* The episode a viewer should land on when they open a series.
|
||||
*
|
||||
* "Current" is domain policy, not layout: an episode in progress, else the
|
||||
* server's Next Up for the series, else the first unwatched episode, else the
|
||||
* first. The third rung is what makes this work offline, where Next Up is
|
||||
* always empty. Returns `None` only when the series has no episodes at all.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101
|
||||
*/
|
||||
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
|
||||
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
|
||||
},
|
||||
/**
|
||||
* Erase the viewer's watch history for an item.
|
||||
*
|
||||
* Clears the played flag and the resume position; on a series or season the
|
||||
* server applies it to everything inside. A series cleared this way is "never
|
||||
* watched" again, so `repository_get_series_current_episode` returns its
|
||||
* premiere. Requires the server — offline this fails rather than diverging
|
||||
* local state the next sync would overwrite.
|
||||
*
|
||||
* TRACES: UR-064 | DR-106
|
||||
*/
|
||||
async repositoryClearWatchHistory(handle: string, itemId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_clear_watch_history", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Get recently played audio
|
||||
*/
|
||||
@@ -1380,6 +1462,20 @@ async repositoryMarkFavorite(handle: string, itemId: string) : Promise<null> {
|
||||
async repositoryUnmarkFavorite(handle: string, itemId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_unmark_favorite", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Everything the viewer has favourited, across libraries, narrowed by scope.
|
||||
*
|
||||
* Two-phase like `repository_search`: the local answer returns immediately and
|
||||
* a background server pass emits `favorites-changed` when the server's set
|
||||
* differs. Without the second phase a favourite marked in another client shows
|
||||
* up only on the *second* visit to the page, since the cache-first read hands
|
||||
* back local rows and the refresh is invisible to the frontend.
|
||||
*
|
||||
* TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
|
||||
*/
|
||||
async repositoryGetFavorites(handle: string, scope: SearchScope, options: GetItemsOptions | null) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_get_favorites", { handle, scope, options });
|
||||
},
|
||||
/**
|
||||
* Get person details
|
||||
*/
|
||||
@@ -1662,7 +1758,15 @@ storageLimit: number;
|
||||
/**
|
||||
* Only cache on WiFi
|
||||
*/
|
||||
wifiOnly: boolean }
|
||||
wifiOnly: boolean;
|
||||
/**
|
||||
* How long a temporary (`download_source = 'auto'`) download lives before
|
||||
* it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
|
||||
* the only reclaim trigger.
|
||||
*
|
||||
* TRACES: UR-071 | DR-127
|
||||
*/
|
||||
temporaryTtlHours: number }
|
||||
/**
|
||||
* Cached media item returned to frontend
|
||||
*/
|
||||
@@ -1687,7 +1791,12 @@ itemsCached: number;
|
||||
/**
|
||||
* Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||
*/
|
||||
librariesFailed: number }
|
||||
librariesFailed: number;
|
||||
/**
|
||||
* Entries removed because the server no longer has them. Always 0 when any
|
||||
* library failed, since a partial crawl cannot prove an item is gone.
|
||||
*/
|
||||
itemsPruned: number }
|
||||
export type CatalogSyncStatus = {
|
||||
/**
|
||||
* RFC-3339 timestamp of the last successful sync, if any.
|
||||
@@ -1795,7 +1904,14 @@ export type GetImageRequest = { itemId: string; imageType: string; maxWidth?: nu
|
||||
/**
|
||||
* Options for querying items
|
||||
*/
|
||||
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null }
|
||||
export type GetItemsOptions = { startIndex?: number | null; limit?: number | null; sortBy?: string | null; sortOrder?: string | null; includeItemTypes?: string[] | null; recursive?: boolean | null; fields?: string[] | null; genres?: string[] | null;
|
||||
/**
|
||||
* Restrict the listing to favourited items. Backs the per-library
|
||||
* favourites toggle; composes with every other filter here.
|
||||
*
|
||||
* TRACES: UR-067 | DR-116 | UT-104
|
||||
*/
|
||||
favoritesOnly?: boolean | null }
|
||||
/**
|
||||
* Image options
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NO direct HTTP calls - everything routes through Rust backend
|
||||
|
||||
import { commands } from "./bindings";
|
||||
import type { JRayActor, DownloadDiskUsage } from "./bindings";
|
||||
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings";
|
||||
import type { QualityPreset } from "./quality-presets";
|
||||
import type {
|
||||
Library,
|
||||
@@ -137,6 +137,37 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every episode of a series, across all seasons, already in series order.
|
||||
* The backend owns the season fan-out and the flat-series fallback.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101
|
||||
*/
|
||||
async getSeriesEpisodes(seriesId: string): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode the viewer should land on when opening this series. `null` only
|
||||
* when the series has no episodes.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101
|
||||
*/
|
||||
async getSeriesCurrentEpisode(seriesId: string): Promise<MediaItem | null> {
|
||||
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase watch history for an item. On a series or season the server applies
|
||||
* it to everything inside, so the container returns to "never watched".
|
||||
* Requires the server — this fails offline rather than diverging local state.
|
||||
*
|
||||
* TRACES: UR-064 | DR-106
|
||||
*/
|
||||
async clearWatchHistory(itemId: string): Promise<void> {
|
||||
await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
|
||||
}
|
||||
@@ -280,6 +311,20 @@ export class RepositoryClient {
|
||||
await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything favourited, across libraries, narrowed by an opaque scope the
|
||||
* backend expands into item types. The frontend never names a Jellyfin type
|
||||
* here — see docs/specs/scoped-search-boundary.md.
|
||||
*
|
||||
* Resolves with the local answer; a later `favorites-changed` event reports
|
||||
* ids the server disagreed with.
|
||||
*
|
||||
* TRACES: UR-067 | DR-115
|
||||
*/
|
||||
async getFavorites(scope: SearchScope, options?: GetItemsOptions): Promise<SearchResult> {
|
||||
return commands.repositoryGetFavorites(this.ensureHandle(), scope, options ?? null);
|
||||
}
|
||||
|
||||
// ===== Person Methods (via Rust) =====
|
||||
|
||||
async getPerson(personId: string): Promise<MediaItem> {
|
||||
|
||||
@@ -11,9 +11,16 @@
|
||||
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
|
||||
the browser's flex layout does it exactly, every frame.
|
||||
|
||||
The Android system gesture bar is cleared via `env(safe-area-inset-bottom)`.
|
||||
The Android navigation/gesture bar is cleared via `--safe-bottom` (see
|
||||
app.css). The padding sits INSIDE this element's `bg-surface` box on purpose,
|
||||
so the surface colour extends behind the gesture bar instead of leaving a
|
||||
strip of page background under the nav.
|
||||
|
||||
TRACES: UR-005 | DR-009
|
||||
Never pad from a bare CSS `env()` safe-area value here: Android WebView does
|
||||
not report the system bars that way, so it is always 0 and the nav ends up
|
||||
under the navigation bar (UR-066).
|
||||
|
||||
TRACES: UR-005, UR-066 | DR-009, DR-112
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -41,7 +48,7 @@
|
||||
</script>
|
||||
|
||||
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
|
||||
<div class="flex-shrink-0 pb-[env(safe-area-inset-bottom)] bg-[var(--color-surface)]">
|
||||
<div class="flex-shrink-0 pb-[var(--safe-bottom)] bg-[var(--color-surface)]">
|
||||
{#if showMiniPlayer}
|
||||
<MiniPlayer
|
||||
media={$currentMedia}
|
||||
|
||||
@@ -1,27 +1,60 @@
|
||||
<!-- TRACES: UR-017, UR-068 | DR-021, DR-119 -->
|
||||
<script lang="ts">
|
||||
import { toggleFavorite } from "$lib/services/favorites";
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
isFavorite?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
/**
|
||||
* "button" (default) is the standalone control used in header/hero rows;
|
||||
* "overlay" is the artwork corner variant used on cards, which needs its
|
||||
* own scrim to stay legible over any poster.
|
||||
*/
|
||||
variant?: "button" | "overlay";
|
||||
/** Stop the click reaching a parent card/row that would navigate or play. */
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
let { itemId, isFavorite = $bindable(false), size = "md", className = "" }: Props = $props();
|
||||
let {
|
||||
itemId,
|
||||
isFavorite = $bindable(false),
|
||||
size = "md",
|
||||
className = "",
|
||||
variant = "button",
|
||||
stopPropagation = false,
|
||||
}: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isAnimating = $state(false);
|
||||
|
||||
// A toggle from any other surface (or the backend's `favorites-changed`
|
||||
// refresh) wins over the prop we were mounted with — otherwise a heart tapped
|
||||
// on a card would still read empty on the detail page behind it.
|
||||
$effect(() => {
|
||||
const override = $favoriteOverrides.get(itemId);
|
||||
if (override !== undefined && override !== isFavorite) {
|
||||
isFavorite = override;
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
};
|
||||
|
||||
async function handleToggle() {
|
||||
async function handleToggle(event: MouseEvent) {
|
||||
// On a card the heart sits inside a clickable tile; without this, hearting
|
||||
// an item would also open (or play) it.
|
||||
if (stopPropagation) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
@@ -55,8 +88,15 @@
|
||||
|
||||
// Compute button classes
|
||||
const buttonClass = $derived.by(() => {
|
||||
const baseClasses = "p-2 rounded-full transition-all";
|
||||
const colorClasses = isFavorite ? "text-red-500 hover:text-red-400" : "text-gray-400 hover:text-white";
|
||||
const baseClasses =
|
||||
variant === "overlay"
|
||||
? "p-1.5 rounded-full transition-all bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
: "p-2 rounded-full transition-all";
|
||||
const colorClasses = isFavorite
|
||||
? "text-red-500 hover:text-red-400"
|
||||
: variant === "overlay"
|
||||
? "text-white/80 hover:text-white"
|
||||
: "text-gray-400 hover:text-white";
|
||||
const loadingClasses = isLoading ? "opacity-50 cursor-wait" : "";
|
||||
return `${baseClasses} ${colorClasses} ${loadingClasses} ${className}`.trim();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Library, MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
|
||||
import { formatBytes } from "$lib/utils/formatBytes";
|
||||
import {
|
||||
downloadedCatalog,
|
||||
@@ -62,6 +63,16 @@
|
||||
void openLibrary(item as Library);
|
||||
return;
|
||||
}
|
||||
// Seasons and episodes resolve inside their series (DR-103): a season has
|
||||
// no page of its own and an episode is never browsed bare.
|
||||
if (item.kind === "season") {
|
||||
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "episode") {
|
||||
goto(episodeFocusHref(item));
|
||||
return;
|
||||
}
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
|
||||
interface Props {
|
||||
artist: MediaItem;
|
||||
@@ -126,7 +128,15 @@
|
||||
{/if}
|
||||
|
||||
<!-- Artist Name -->
|
||||
<h1 class="text-4xl font-bold text-white mb-4">{artist.name}</h1>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<h1 class="text-4xl font-bold text-white">{artist.name}</h1>
|
||||
<!-- TRACES: UR-068 | DR-119 -->
|
||||
<FavoriteButton
|
||||
itemId={artist.id}
|
||||
isFavorite={resolveIsFavorite(artist, $favoriteOverrides)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
{#if artist.overview}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<!--
|
||||
Erase watch history for a series or a season.
|
||||
|
||||
The backend does the work (`repository_clear_watch_history` → Jellyfin's
|
||||
mark-unplayed, which is recursive over a container and also zeroes resume
|
||||
positions); this only confirms the intent and reports the outcome. Clearing a
|
||||
series returns it to "never watched", so it reopens on S1E1.
|
||||
|
||||
TRACES: UR-064 | DR-106
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
|
||||
interface Props {
|
||||
/** Series or season id to clear. */
|
||||
itemId: string;
|
||||
/** Name shown in the confirm prompt. */
|
||||
itemName: string;
|
||||
/** What is being cleared, for the prompt wording. */
|
||||
scope: "series" | "season";
|
||||
size?: "sm" | "lg";
|
||||
/** Called after a successful clear so the caller can reload. */
|
||||
onCleared?: () => void;
|
||||
}
|
||||
|
||||
let { itemId, itemName, scope, size = "lg", onCleared }: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
const label = $derived(scope === "series" ? "Clear history" : "Clear season history");
|
||||
|
||||
async function handleClick() {
|
||||
if (busy) return;
|
||||
|
||||
const subject = scope === "series" ? `all of “${itemName}”` : `“${itemName}”`;
|
||||
// Destructive and not undoable — always ask, even though the server keeps
|
||||
// no undo of its own.
|
||||
if (
|
||||
!confirm(
|
||||
`Erase watch history for ${subject}?\n\n` +
|
||||
"Every episode is marked unwatched and resume positions are cleared. " +
|
||||
"This cannot be undone."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
await auth.getRepository().clearWatchHistory(itemId);
|
||||
onCleared?.();
|
||||
} catch (e) {
|
||||
console.error("Failed to clear watch history:", e);
|
||||
alert(
|
||||
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={handleClick}
|
||||
disabled={busy || !$isServerReachable}
|
||||
title={$isServerReachable
|
||||
? "Mark everything unwatched and clear resume positions"
|
||||
: "Needs a connection to the server"}
|
||||
class="rounded-lg font-medium flex items-center gap-2 transition-colors
|
||||
bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
{size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm'}"
|
||||
>
|
||||
{#if busy}
|
||||
<div
|
||||
class="border-2 border-current border-t-transparent rounded-full animate-spin
|
||||
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
||||
></div>
|
||||
{:else}
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
|
||||
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
|
||||
0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{busy ? "Clearing…" : label}
|
||||
</button>
|
||||
@@ -4,7 +4,13 @@
|
||||
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";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import {
|
||||
isCurrentEpisode as isSameEpisode,
|
||||
adjacentEpisodes as computeAdjacent,
|
||||
stripCardLabel,
|
||||
} from "./episodeStrip";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
@@ -162,8 +168,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Play button -->
|
||||
<div class="pt-2">
|
||||
<!-- Play button + favourite. TRACES: UR-068 | DR-119 -->
|
||||
<div class="pt-2 flex items-center gap-3">
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
||||
@@ -173,6 +179,11 @@
|
||||
</svg>
|
||||
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
||||
</button>
|
||||
<FavoriteButton
|
||||
itemId={episode.id}
|
||||
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,8 +256,8 @@
|
||||
<!-- Episode info -->
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
|
||||
{ep.indexNumber || 0}.
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
|
||||
{stripCardLabel(ep, episode)}
|
||||
</span>
|
||||
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
||||
{ep.name}
|
||||
|
||||
@@ -10,15 +10,21 @@
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
focused?: boolean;
|
||||
/**
|
||||
* This is the episode the viewer is up to. Marked and scrolled to when the
|
||||
* series page opens, so a viewer four seasons deep lands on their place
|
||||
* instead of the top of season 1. TRACES: UR-062 | DR-102
|
||||
*/
|
||||
current?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { episode, focused = false, onclick }: Props = $props();
|
||||
let { episode, focused = false, current = false, onclick }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (focused && buttonRef) {
|
||||
if ((focused || current) && buttonRef) {
|
||||
// Scroll into view with some offset from top
|
||||
setTimeout(() => {
|
||||
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -51,7 +57,11 @@
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
type="button"
|
||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
|
||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
||||
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: current
|
||||
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
|
||||
: ''}"
|
||||
{onclick}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
@@ -137,6 +147,13 @@
|
||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||
{truncateMiddle(episode.name, 56)}
|
||||
</h3>
|
||||
{#if current}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
>
|
||||
Up next
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Played indicator -->
|
||||
{#if episode.userData?.isPlayed}
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -34,9 +34,16 @@
|
||||
|
||||
interface Props {
|
||||
config: GenreConfig;
|
||||
/**
|
||||
* Suppress the back button + title when this renders as a *tab* of a
|
||||
* library page that already has a header. Drilling into a single genre
|
||||
* still shows the header — there the back button is the way out.
|
||||
* TRACES: UR-063 | DR-105
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
let { config, showHeader = true }: Props = $props();
|
||||
|
||||
let genres = $state<Genre[]>([]);
|
||||
let filteredGenres = $state<Genre[]>([]);
|
||||
@@ -153,17 +160,20 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">
|
||||
{#if selectedGenre}
|
||||
{selectedGenre.name}
|
||||
{:else}
|
||||
{config.title}
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Header. Inside a genre the back button is the only way out, so it shows
|
||||
even when the host page suppresses the top-level header. -->
|
||||
{#if showHeader || selectedGenre}
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">
|
||||
{#if selectedGenre}
|
||||
{selectedGenre.name}
|
||||
{:else}
|
||||
{config.title}
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !selectedGenre}
|
||||
<!-- Genre Browser -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
|
||||
<!-- TRACES: UR-007, UR-029, UR-030, UR-067 | DR-007, DR-032, DR-033, DR-116 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -41,15 +41,22 @@
|
||||
|
||||
interface Props {
|
||||
config: MediaListConfig;
|
||||
/**
|
||||
* Suppress the back button + title. Set when this renders as a *tab* of a
|
||||
* library page, which already has its own header — two stacked headers and
|
||||
* two back buttons read as two pages. TRACES: UR-063 | DR-105
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
let { config, showHeader = true }: Props = $props();
|
||||
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let gridWrapper = $state<HTMLDivElement | null>(null);
|
||||
let searchQuery = $state("");
|
||||
let debouncedSearchQuery = $state("");
|
||||
let favoritesOnly = $state(false);
|
||||
let sortBy = $state<string>("");
|
||||
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -134,6 +141,9 @@
|
||||
sortOrder,
|
||||
recursive: true,
|
||||
limit: 10000,
|
||||
// Narrows the listing in place; the backend owns what "favourite"
|
||||
// resolves to online vs offline. TRACES: UR-067 | DR-116
|
||||
favoritesOnly: favoritesOnly ? true : undefined,
|
||||
});
|
||||
items = excludePodcasts(result.items);
|
||||
}
|
||||
@@ -148,6 +158,12 @@
|
||||
searchQuery = query;
|
||||
}
|
||||
|
||||
/// TRACES: UR-067 | DR-116
|
||||
function toggleFavoritesOnly() {
|
||||
favoritesOnly = !favoritesOnly;
|
||||
loadItems();
|
||||
}
|
||||
|
||||
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
|
||||
$effect(() => {
|
||||
const _query = searchQuery; // track for reactivity
|
||||
@@ -246,10 +262,12 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
||||
</div>
|
||||
{#if showHeader}
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search and Sort Bar -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
@@ -258,6 +276,33 @@
|
||||
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
||||
</div>
|
||||
|
||||
<!-- Favourites filter. Session-scoped on purpose: a persisted filter that
|
||||
hides most of a library reads as data loss on the next launch
|
||||
(ux-flows §5C.2). Hidden while searching, which has no favourites
|
||||
filter of its own. TRACES: UR-067 | DR-116 -->
|
||||
{#if !debouncedSearchQuery.trim()}
|
||||
<button
|
||||
onclick={toggleFavoritesOnly}
|
||||
aria-pressed={favoritesOnly}
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors
|
||||
{favoritesOnly
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-[var(--color-surface)] text-gray-400 hover:text-white'}"
|
||||
title={favoritesOnly ? "Showing favourites only" : "Show favourites only"}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill={favoritesOnly ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
Favourites
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Sort (only show if there are sort options) -->
|
||||
{#if config.sortOptions.length > 0}
|
||||
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import type { MediaKind } from "$lib/api/types";
|
||||
import { libraryViewUrl } from "$lib/utils/libraryView";
|
||||
|
||||
interface Props {
|
||||
genres: string[];
|
||||
@@ -17,7 +18,9 @@
|
||||
itemKind
|
||||
}: Props = $props();
|
||||
|
||||
// Map the item kind to its genre-browse route
|
||||
// Map the item kind to its genre-browse surface. Video genres are a tab of
|
||||
// the library page now, not a route of their own (DR-105); linking straight
|
||||
// to the tab avoids a redirect hop through the legacy paths.
|
||||
function genreBasePath(kind: MediaKind | undefined): string {
|
||||
switch (kind) {
|
||||
case "album":
|
||||
@@ -28,11 +31,11 @@
|
||||
case "series":
|
||||
case "season":
|
||||
case "episode":
|
||||
return "/library/shows/genres";
|
||||
return libraryViewUrl("/library/tv", "genres");
|
||||
case "movie":
|
||||
return "/library/movies/genres";
|
||||
return libraryViewUrl("/library/movies", "genres");
|
||||
default:
|
||||
return "/library/movies/genres";
|
||||
return libraryViewUrl("/library/movies", "genres");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
Browse / All / Genres for a video library.
|
||||
|
||||
These were three routes per library with names that did not agree across the
|
||||
two libraries; they are now tabs on one route, driven by `?view=` so a tab is
|
||||
linkable and survives a back navigation.
|
||||
|
||||
TRACES: UR-063 | DR-105
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { LIBRARY_VIEWS, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
|
||||
|
||||
interface Props {
|
||||
/** Route the tabs live on, e.g. `/library/tv`. */
|
||||
basePath: string;
|
||||
active: LibraryView;
|
||||
/** Per-view labels — "All Shows" vs "All Movies". */
|
||||
labels: Record<LibraryView, string>;
|
||||
}
|
||||
|
||||
let { basePath, active, labels }: Props = $props();
|
||||
|
||||
function select(view: LibraryView) {
|
||||
if (view === active) return;
|
||||
// replaceState: switching tabs is not a navigation step worth a back press.
|
||||
goto(libraryViewUrl(basePath, view), { replaceState: true, noScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="flex items-center gap-1 px-4" aria-label="Library sections">
|
||||
{#each LIBRARY_VIEWS as view (view)}
|
||||
<button
|
||||
onclick={() => select(view)}
|
||||
aria-current={view === active ? "page" : undefined}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{view === active
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
||||
>
|
||||
{labels[view]}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
||||
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
@@ -7,6 +7,8 @@
|
||||
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
@@ -37,9 +39,15 @@
|
||||
* TRACES: UR-058 | DR-087
|
||||
*/
|
||||
onLongPress?: () => void;
|
||||
/**
|
||||
* Show the favourite heart on the artwork. On by default for media items;
|
||||
* surfaces that are not about the item itself can opt out.
|
||||
* TRACES: UR-068 | DR-119
|
||||
*/
|
||||
showFavorite?: boolean;
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: Props = $props();
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
|
||||
|
||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||
@@ -120,6 +128,13 @@
|
||||
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
|
||||
);
|
||||
|
||||
// The heart is about an item, so libraries never get one, and a greyed
|
||||
// server-only card has nothing actionable to offer. TRACES: UR-068 | DR-119
|
||||
const showHeart = $derived(showFavorite && isMediaItem && !isServerOnly);
|
||||
const isFavorited = $derived(
|
||||
isMediaItem ? resolveIsFavorite(item as MediaItem, $favoriteOverrides) : false
|
||||
);
|
||||
|
||||
let queueError = $state<string | null>(null);
|
||||
|
||||
// Queue this item for download on next reconnect. Offline, this just persists
|
||||
@@ -250,12 +265,34 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Played indicator -->
|
||||
{#if "userData" in item && item.userData?.isPlayed}
|
||||
<div class="absolute top-2 right-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
<!-- Top-right status stack: played tick, then the favourite heart. Grouped
|
||||
so the two never land on the same pixels when both apply. -->
|
||||
{#if ("userData" in item && item.userData?.isPlayed) || showHeart}
|
||||
<div class="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||
{#if "userData" in item && item.userData?.isPlayed}
|
||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{#if showHeart}
|
||||
<!-- Always visible on touch (no hover to reveal it); on pointer
|
||||
devices an unfavourited heart stays out of the way until the card
|
||||
is hovered or focused. A favourited one is always shown — it is
|
||||
state, not an affordance. TRACES: UR-068 | DR-119 -->
|
||||
<div
|
||||
class="transition-opacity {isFavorited
|
||||
? ''
|
||||
: 'opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/card:opacity-100 [@media(hover:hover)]:group-focus-within/card:opacity-100'}"
|
||||
>
|
||||
<FavoriteButton
|
||||
itemId={item.id}
|
||||
isFavorite={isFavorited}
|
||||
size="sm"
|
||||
variant="overlay"
|
||||
stopPropagation
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
|
||||
interface Props {
|
||||
@@ -214,6 +216,13 @@
|
||||
</svg>
|
||||
Shuffle
|
||||
</button>
|
||||
<!-- TRACES: UR-068 | DR-119 -->
|
||||
<FavoriteButton
|
||||
itemId={playlist.id}
|
||||
isFavorite={$favoriteOverrides.get(playlist.id) ?? false}
|
||||
size="lg"
|
||||
className="self-center"
|
||||
/>
|
||||
<button
|
||||
onclick={() => showDeleteConfirm = true}
|
||||
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { seasonAnchorId } from "./seriesNavigation";
|
||||
|
||||
interface Props {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
focusedEpisodeId?: string;
|
||||
/** The episode the viewer is up to — highlighted and scrolled into view. */
|
||||
currentEpisodeId?: string;
|
||||
/**
|
||||
* Whether this season's episode list is open. Only the current season
|
||||
* starts expanded, so a ten-season show does not render every episode at
|
||||
* once. TRACES: UR-062 | DR-107
|
||||
*/
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
onEpisodeClick?: (episode: MediaItem) => void;
|
||||
onHistoryCleared?: () => void;
|
||||
}
|
||||
|
||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||
let {
|
||||
season,
|
||||
episodes,
|
||||
focusedEpisodeId,
|
||||
currentEpisodeId,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
onEpisodeClick,
|
||||
onHistoryCleared,
|
||||
}: Props = $props();
|
||||
|
||||
const holdsCurrentEpisode = $derived(
|
||||
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
|
||||
);
|
||||
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
|
||||
|
||||
const episodeCount = $derived(episodes.length);
|
||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
);
|
||||
// Seasons have no page of their own; a season link scrolls to this anchor
|
||||
// inside the series' single continuous episode list.
|
||||
const anchor = $derived(seasonAnchorId(seasonNumber));
|
||||
</script>
|
||||
|
||||
<section class="space-y-4">
|
||||
<section class="space-y-4 scroll-mt-4" id={anchor}>
|
||||
<!-- Season header -->
|
||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||
<!-- Season poster -->
|
||||
@@ -38,28 +68,60 @@
|
||||
<!-- Season info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{seasonName}
|
||||
<!-- The whole title block toggles the season open/closed. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="{anchor}-episodes"
|
||||
class="flex-1 min-w-0 text-left group/season"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
||||
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="truncate">{seasonName}</span>
|
||||
{#if holdsCurrentEpisode}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
>
|
||||
Up next
|
||||
</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
|
||||
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
|
||||
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
|
||||
<!-- Collapsed, this is the only progress signal the season shows. -->
|
||||
{#if watchedCount > 0}
|
||||
<span>•</span>
|
||||
<span>
|
||||
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
|
||||
</span>
|
||||
{/if}
|
||||
{#if season.productionYear}
|
||||
<span>•</span>
|
||||
<span>{season.productionYear}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if season.overview}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
|
||||
{#if season.overview && expanded}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
|
||||
{season.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Download Season Button -->
|
||||
<div class="flex-shrink-0">
|
||||
<!-- Per-season actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
@@ -68,19 +130,29 @@
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={season.id}
|
||||
itemName={seasonName}
|
||||
scope="season"
|
||||
size="sm"
|
||||
onCleared={onHistoryCleared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode list -->
|
||||
<div class="space-y-1 pl-2">
|
||||
{#each episodes as episode (episode.id)}
|
||||
<EpisodeRow
|
||||
{episode}
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if expanded}
|
||||
<div class="space-y-1 pl-2" id="{anchor}-episodes">
|
||||
{#each episodes as episode (episode.id)}
|
||||
<EpisodeRow
|
||||
{episode}
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
current={episode.id === currentEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
|
||||
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
|
||||
|
||||
// Minimal episode factory — only the fields the strip logic reads.
|
||||
function ep(
|
||||
@@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => {
|
||||
expect(strip).toContain(current);
|
||||
});
|
||||
|
||||
it("restricts to the current season when multiple seasons are present", () => {
|
||||
// ux-flows §5B.2, "Cross-season continuity": the window spans the whole
|
||||
// series in episode order, so it runs past a season boundary rather than
|
||||
// dead-ending at the end of a season.
|
||||
it("runs past the end of a season into the next one", () => {
|
||||
const eps = [...season(1, 5), ...season(2, 5)];
|
||||
const current = eps[6]; // S2E2
|
||||
const current = eps[4]; // S1E5 — the season finale
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
|
||||
expect(strip.map((e) => e.id)).toEqual([
|
||||
"s1e2", "s1e3", "s1e4", "s1e5",
|
||||
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reaches back into the previous season from a season opener", () => {
|
||||
const eps = [...season(1, 5), ...season(2, 5)];
|
||||
const current = eps[5]; // S2E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]);
|
||||
expect(strip[3].id).toBe("s2e1");
|
||||
});
|
||||
|
||||
it("orders by season then episode, never interleaving seasons", () => {
|
||||
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
|
||||
const current = eps[3]; // S1E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.map((e) => e.id)).toEqual([
|
||||
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts specials (season 0) after the numbered seasons", () => {
|
||||
const eps = [...season(0, 2), ...season(1, 2)];
|
||||
const current = eps[2]; // S1E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]);
|
||||
});
|
||||
|
||||
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
|
||||
@@ -93,5 +123,42 @@ describe("adjacentEpisodes", () => {
|
||||
const current = ep("mystery", null, 3); // no season number
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.length).toBeGreaterThan(1);
|
||||
// Anchored at its episode number, not dumped at one end of the list.
|
||||
expect(strip.indexOf(current)).toBeGreaterThan(0);
|
||||
expect(strip.indexOf(current)).toBeLessThan(strip.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareSeriesOrder", () => {
|
||||
it("orders by season, then episode", () => {
|
||||
expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0);
|
||||
expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0);
|
||||
expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0);
|
||||
});
|
||||
|
||||
it("puts specials last", () => {
|
||||
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0);
|
||||
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to episode number when a season is unknown", () => {
|
||||
expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripCardLabel", () => {
|
||||
const current = ep("cur", 2, 4);
|
||||
|
||||
it("shows a bare episode number within the current season", () => {
|
||||
expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6.");
|
||||
});
|
||||
|
||||
it("shows SxEy once the card crosses a season boundary", () => {
|
||||
expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1");
|
||||
expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8");
|
||||
});
|
||||
|
||||
it("degrades to the episode number when the season is unknown", () => {
|
||||
expect(stripCardLabel(ep("a", null, 7), current)).toBe("7.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// 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.
|
||||
// collapse to just the current episode while real siblings exist, must not
|
||||
// mistake number-less episodes for the current one, and must run past a season
|
||||
// boundary rather than dead-ending at the end of a season (ux-flows §5B.2).
|
||||
//
|
||||
// TRACES: UR-048 | DR-062
|
||||
// TRACES: UR-048, UR-062 | DR-062, DR-104
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/** Episodes shown before / after the current one in the strip window. */
|
||||
const BEFORE = 3;
|
||||
const AFTER = 6;
|
||||
|
||||
/** Jellyfin puts specials in season 0; they air outside the numbered run. */
|
||||
const SPECIALS_SEASON = 0;
|
||||
|
||||
/**
|
||||
* Does `ep` refer to the same episode as `current`?
|
||||
*
|
||||
@@ -29,33 +37,74 @@ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key for a season: specials (season 0) come *after* every numbered
|
||||
* season, matching how a viewer works through a show — S1, S2, …, then the
|
||||
* extras — rather than opening on a special because 0 < 1.
|
||||
*/
|
||||
function seasonRank(seasonNumber: number | null | undefined): number {
|
||||
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast order across a whole series: season ascending, then episode.
|
||||
*
|
||||
* When *either* side's season is unknown there is no season axis to compare on,
|
||||
* so it falls through to episode number. That makes the comparator technically
|
||||
* non-transitive across such a mix, which is safe here because only the
|
||||
* directly-fetched `current` episode can lack a season and it is never part of
|
||||
* the array being sorted — it is only positioned against it (see
|
||||
* `adjacentEpisodes`).
|
||||
*/
|
||||
export function compareSeriesOrder(a: MediaItem, b: MediaItem): number {
|
||||
if (a.parentIndexNumber != null && b.parentIndexNumber != null) {
|
||||
const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber);
|
||||
if (bySeason !== 0) return bySeason;
|
||||
}
|
||||
return (a.indexNumber ?? 0) - (b.indexNumber ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* current episode, in series order across *all* seasons.
|
||||
*
|
||||
* Crossing a season boundary is the point (ux-flows §5B.2): finishing a season
|
||||
* finale should offer the next season's premiere, not an empty strip. Degrades
|
||||
* gracefully:
|
||||
* - splices the current episode into the pool at its ordered position when it
|
||||
* isn't present (an API id mismatch on a directly-fetched episode), 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));
|
||||
const pool = allEpisodes.slice().sort(compareSeriesOrder);
|
||||
|
||||
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);
|
||||
const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0);
|
||||
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);
|
||||
const start = Math.max(0, idx - BEFORE);
|
||||
const end = Math.min(pool.length, idx + AFTER + 1);
|
||||
return pool.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a strip card, relative to the episode in focus.
|
||||
*
|
||||
* Within the current season a bare number reads cleanly ("6."). Once the window
|
||||
* crosses into another season that number is ambiguous, so the card names the
|
||||
* season too ("S3E1") — otherwise the premiere after a finale just reads "1."
|
||||
*/
|
||||
export function stripCardLabel(ep: MediaItem, current: MediaItem): string {
|
||||
const crossesSeason =
|
||||
ep.parentIndexNumber != null &&
|
||||
current.parentIndexNumber != null &&
|
||||
ep.parentIndexNumber !== current.parentIndexNumber;
|
||||
|
||||
if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`;
|
||||
return `${ep.indexNumber ?? 0}.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import {
|
||||
seasonAnchorId,
|
||||
seasonRedirectTarget,
|
||||
episodeFocusHref,
|
||||
seriesPlayHref,
|
||||
seriesPlayLabel,
|
||||
groupEpisodesBySeason,
|
||||
initialExpandedSeasons,
|
||||
} from "./seriesNavigation";
|
||||
|
||||
const SERIES = "series-1";
|
||||
|
||||
function ep(id: string, season: number | null, number: number | null): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `S${season}E${number}`,
|
||||
kind: "episode",
|
||||
seriesId: SERIES,
|
||||
parentIndexNumber: season,
|
||||
indexNumber: number,
|
||||
durationMs: 1_000_000,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `Season ${number}`,
|
||||
kind: "season",
|
||||
seriesId: SERIES,
|
||||
indexNumber: number,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
function withProgress(episode: MediaItem, fraction: number): MediaItem {
|
||||
return {
|
||||
...episode,
|
||||
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
describe("seriesPlayHref", () => {
|
||||
// The reported bug: Play resolved the first *season* child and navigated to
|
||||
// /player/<seasonId>, which bounced back to the season-1 page.
|
||||
it("opens the current episode's focus view, never a season or the player", () => {
|
||||
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
|
||||
expect(href).toBe("/library/series-1?episode=s2e4");
|
||||
expect(href).not.toContain("/player/");
|
||||
});
|
||||
|
||||
it("returns null for a series with no episodes so the button can hide", () => {
|
||||
expect(seriesPlayHref(SERIES, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seriesPlayLabel", () => {
|
||||
it("names the episode it will open", () => {
|
||||
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
|
||||
});
|
||||
|
||||
it("says Resume for a part-watched episode", () => {
|
||||
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
|
||||
});
|
||||
|
||||
it("says Play for a barely-started or nearly-finished episode", () => {
|
||||
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
|
||||
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
|
||||
});
|
||||
|
||||
it("degrades to a bare verb when the numbering is unknown", () => {
|
||||
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
|
||||
expect(seriesPlayLabel(null)).toBe("Play");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seasonRedirectTarget", () => {
|
||||
it("sends a season to its series, anchored at that season", () => {
|
||||
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
|
||||
});
|
||||
|
||||
it("returns null when the series is unknown, so the caller can fall back", () => {
|
||||
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
|
||||
expect(seasonRedirectTarget(orphan)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches the anchor the season section renders", () => {
|
||||
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
|
||||
`/library/${SERIES}#${seasonAnchorId(2)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("episodeFocusHref", () => {
|
||||
it("opens an episode inside its series (never a bare episode page)", () => {
|
||||
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
|
||||
});
|
||||
|
||||
it("falls back to the bare item page when the series is unknown", () => {
|
||||
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
|
||||
expect(episodeFocusHref(orphan)).toBe("/library/lone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupEpisodesBySeason", () => {
|
||||
it("groups episodes under their season headers, in season order", () => {
|
||||
const seasons = [seasonHeader(2), seasonHeader(1)];
|
||||
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
|
||||
|
||||
const grouped = groupEpisodesBySeason(seasons, episodes);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
|
||||
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
|
||||
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
|
||||
});
|
||||
|
||||
it("puts specials after the numbered seasons", () => {
|
||||
const grouped = groupEpisodesBySeason(
|
||||
[seasonHeader(0), seasonHeader(1)],
|
||||
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
|
||||
);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
// A flat series: episodes hang off the series, no season folders exist.
|
||||
it("synthesizes headers when the server returned no seasons", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
|
||||
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
|
||||
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
|
||||
});
|
||||
|
||||
it("names a synthesized season 0 'Specials'", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
|
||||
expect(grouped[0].season.name).toBe("Specials");
|
||||
});
|
||||
|
||||
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
|
||||
const ids = grouped.map((g) => g.season.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("drops seasons that have no episodes", () => {
|
||||
const grouped = groupEpisodesBySeason(
|
||||
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
||||
[ep("s2e1", 2, 1)]
|
||||
);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("buckets season-less episodes into season 1 rather than losing them", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialExpandedSeasons", () => {
|
||||
const seasons = groupEpisodesBySeason(
|
||||
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
||||
[
|
||||
ep("s1e1", 1, 1),
|
||||
ep("s2e1", 2, 1),
|
||||
ep("s2e2", 2, 2),
|
||||
ep("s3e1", 3, 1),
|
||||
]
|
||||
);
|
||||
|
||||
it("expands only the season holding the current episode", () => {
|
||||
const expanded = initialExpandedSeasons(seasons, "s2e2");
|
||||
expect([...expanded]).toEqual(["season-2"]);
|
||||
});
|
||||
|
||||
it("also expands the season of a ?episode= deep link", () => {
|
||||
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
|
||||
expect(expanded.has("season-1")).toBe(true);
|
||||
expect(expanded.has("season-3")).toBe(true);
|
||||
expect(expanded.has("season-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses nothing extra when current and focused share a season", () => {
|
||||
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
|
||||
expect([...expanded]).toEqual(["season-2"]);
|
||||
});
|
||||
|
||||
it("falls back to the first season when there is no current episode", () => {
|
||||
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
|
||||
});
|
||||
|
||||
it("falls back to the first season when the current episode is unknown here", () => {
|
||||
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
|
||||
});
|
||||
|
||||
it("returns nothing for a series with no seasons", () => {
|
||||
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Pure navigation/grouping logic for the series detail page.
|
||||
//
|
||||
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
|
||||
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
|
||||
// SortName — and navigate to `/player/<seasonId>`, which the player route
|
||||
// bounced back to `/library/<seasonId>`. Play on a series therefore played
|
||||
// nothing and landed on the season-1 page.
|
||||
//
|
||||
// Note what is NOT here: *which* episode is current. That is domain policy and
|
||||
// lives in Rust (`repository_get_series_current_episode`); this module only
|
||||
// renders and routes around the answer.
|
||||
//
|
||||
// TRACES: UR-062 | DR-102, DR-103
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
export interface SeasonData {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
}
|
||||
|
||||
/** Jellyfin files specials under season 0. */
|
||||
const SPECIALS_SEASON = 0;
|
||||
|
||||
/** Sort key for a season number: specials come after every numbered season. */
|
||||
function seasonRank(seasonNumber: number | null | undefined): number {
|
||||
if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
|
||||
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-page anchor for a season, so a season link scrolls the series' single
|
||||
* continuous episode list instead of opening a page of its own.
|
||||
*/
|
||||
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
|
||||
return `season-${seasonNumber ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a link naming a season should actually go: the series, anchored at that
|
||||
* season. Returns `null` when the season carries no `seriesId` (a deep link into
|
||||
* a stale cache), in which case the caller must keep rendering something rather
|
||||
* than strand the user.
|
||||
*/
|
||||
export function seasonRedirectTarget(season: MediaItem): string | null {
|
||||
if (!season.seriesId) return null;
|
||||
const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
|
||||
return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an episode link should go: the episode in the context of its series
|
||||
* (ux-flows §5B.1 — an episode is never browsed as a bare Episode page).
|
||||
* Falls back to the bare item page only when the series is unknown.
|
||||
*/
|
||||
export function episodeFocusHref(episode: MediaItem): string {
|
||||
if (!episode.seriesId) return `/library/${episode.id}`;
|
||||
return `/library/${episode.seriesId}?episode=${episode.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the series hero button goes.
|
||||
*
|
||||
* The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
|
||||
* *container* navigation and Play on a *leaf* the commitment. Returns `null`
|
||||
* when there is no current episode (an empty series), so the caller can hide
|
||||
* the button rather than link nowhere.
|
||||
*/
|
||||
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
|
||||
if (!current) return null;
|
||||
return `/library/${seriesId}?episode=${current.id}`;
|
||||
}
|
||||
|
||||
/** Fraction of an episode already watched, 0 when unknown. */
|
||||
function progressFraction(episode: MediaItem): number {
|
||||
const position = episode.userData?.playbackPositionMs ?? 0;
|
||||
if (!episode.durationMs || position <= 0) return 0;
|
||||
return position / episode.durationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the series hero button — it names the episode it will open, so the
|
||||
* viewer knows where the button leads before pressing it.
|
||||
*/
|
||||
export function seriesPlayLabel(current: MediaItem | null): string {
|
||||
if (!current) return "Play";
|
||||
|
||||
const fraction = progressFraction(current);
|
||||
const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";
|
||||
|
||||
if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
|
||||
return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a series' episodes under its season headers.
|
||||
*
|
||||
* The episodes arrive from Rust already in series order; this only decides which
|
||||
* header each one renders beneath, and synthesizes a header for any season the
|
||||
* server did not return one for (a flat series, or a season fetch that failed).
|
||||
* Seasons with no episodes are dropped — an empty accordion row is noise.
|
||||
*/
|
||||
export function groupEpisodesBySeason(
|
||||
seasons: MediaItem[],
|
||||
episodes: MediaItem[]
|
||||
): SeasonData[] {
|
||||
const headerFor = new Map<number, MediaItem>();
|
||||
for (const season of seasons) {
|
||||
const number = season.indexNumber ?? season.parentIndexNumber;
|
||||
if (number != null && !headerFor.has(number)) headerFor.set(number, season);
|
||||
}
|
||||
|
||||
const grouped = new Map<number, MediaItem[]>();
|
||||
for (const episode of episodes) {
|
||||
const number = episode.parentIndexNumber ?? 1;
|
||||
const bucket = grouped.get(number);
|
||||
if (bucket) bucket.push(episode);
|
||||
else grouped.set(number, [episode]);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.sort(([a], [b]) => seasonRank(a) - seasonRank(b))
|
||||
.map(([number, seasonEpisodes]) => ({
|
||||
season:
|
||||
headerFor.get(number) ??
|
||||
({
|
||||
...seasonEpisodes[0],
|
||||
id: `synthetic-season-${number}`,
|
||||
kind: "season",
|
||||
indexNumber: number,
|
||||
name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
|
||||
overview: null,
|
||||
} as MediaItem),
|
||||
episodes: seasonEpisodes,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which seasons start expanded.
|
||||
*
|
||||
* Only the one the viewer is in. A ten-season show otherwise renders every
|
||||
* episode of every season at once, burying the one episode they came for. A
|
||||
* `?episode=` deep link expands that episode's season as well, and a show with
|
||||
* no resolved current episode falls back to its first season so the page is
|
||||
* never entirely collapsed.
|
||||
*
|
||||
* Returns season ids (not numbers) so the caller can key state per section,
|
||||
* including the synthesized headers.
|
||||
*/
|
||||
export function initialExpandedSeasons(
|
||||
seasons: SeasonData[],
|
||||
currentEpisodeId: string | null | undefined,
|
||||
focusedEpisodeId?: string | null
|
||||
): Set<string> {
|
||||
if (seasons.length === 0) return new Set();
|
||||
|
||||
const expanded = new Set<string>();
|
||||
for (const id of [currentEpisodeId, focusedEpisodeId]) {
|
||||
if (!id) continue;
|
||||
const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
|
||||
if (owner) expanded.add(owner.season.id);
|
||||
}
|
||||
|
||||
// Nothing matched — open the first season rather than nothing at all.
|
||||
if (expanded.size === 0) expanded.add(seasons[0].season.id);
|
||||
|
||||
return expanded;
|
||||
}
|
||||
@@ -154,8 +154,16 @@
|
||||
<div class="fixed inset-0 z-0 bg-[var(--color-background)]"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Content overlay -->
|
||||
<div class="relative z-10 flex flex-col h-full">
|
||||
<!-- Content overlay. The blurred artwork behind it stays edge-to-edge; only
|
||||
this layer is inset, so the close button clears the status bar and the
|
||||
transport controls clear the Android gesture bar. (UR-066) -->
|
||||
<div
|
||||
class="relative z-10 flex flex-col h-full"
|
||||
style:padding-top="var(--safe-top)"
|
||||
style:padding-bottom="var(--safe-bottom)"
|
||||
style:padding-left="var(--safe-left)"
|
||||
style:padding-right="var(--safe-right)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button
|
||||
@@ -329,8 +337,12 @@
|
||||
aria-label="Close queue"
|
||||
></button>
|
||||
|
||||
<!-- Queue Panel -->
|
||||
<div class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up">
|
||||
<!-- Queue Panel. Slides up from the very bottom, so it owns the bottom
|
||||
inset — its last row would otherwise sit under the gesture bar. -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 max-h-[70vh] animate-slide-up"
|
||||
style:padding-bottom="var(--safe-bottom)"
|
||||
>
|
||||
<Queue
|
||||
items={$queueItems}
|
||||
currentIndex={$currentQueueIndex}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 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 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -24,6 +24,9 @@
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
clampSeekTarget,
|
||||
isSynthesizedTouchClick,
|
||||
isControlSurfaceTouch,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
type TapFeedback,
|
||||
@@ -111,7 +114,9 @@
|
||||
let touchStartY = $state(0);
|
||||
let touchStartTime = $state(0);
|
||||
let tapGestures = createTapGestureState();
|
||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// When a touch tap last ran the gesture handler, so the compatibility click
|
||||
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
|
||||
let lastTouchTapAt = 0;
|
||||
let brightness = $state(1); // 0-2, default 1
|
||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -119,6 +124,14 @@
|
||||
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||
let pendingSeekTarget: number | null = null;
|
||||
let swipeGestureActive = $state(false);
|
||||
// Whether the in-flight touch belongs to the player surface (and so may be
|
||||
// read as a tap/swipe gesture) rather than to a control. Set on touchstart,
|
||||
// cleared on touchend — see handleTouchMove for why a per-gesture flag and not
|
||||
// just a per-event target check.
|
||||
let playerGestureActive = false;
|
||||
// Raised when the user changes the seek bar's value, cleared by whichever
|
||||
// release signal commits the seek. See handleSeekBarRelease.
|
||||
let seekCommitArmed = false;
|
||||
|
||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
|
||||
@@ -685,15 +698,19 @@
|
||||
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
||||
}
|
||||
|
||||
console.log("[VideoPlayer Debug]", {
|
||||
currentTime: videoElement.currentTime.toFixed(2),
|
||||
displayTime: currentTime.toFixed(2),
|
||||
buffered: bufferedRanges.join(", "),
|
||||
readyState: videoElement.readyState,
|
||||
paused: videoElement.paused,
|
||||
seeking: videoElement.seeking,
|
||||
playbackRate: videoElement.playbackRate,
|
||||
});
|
||||
// Flattened to a single string on purpose: the Android WebView console
|
||||
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||
// this whole payload useless when diagnosing over adb.
|
||||
console.log(
|
||||
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
||||
` display=${currentTime.toFixed(2)}` +
|
||||
` readyState=${videoElement.readyState}` +
|
||||
` networkState=${videoElement.networkState}` +
|
||||
` paused=${videoElement.paused}` +
|
||||
` seeking=${videoElement.seeking}` +
|
||||
` rate=${videoElement.playbackRate}` +
|
||||
` buffered=${bufferedRanges.join(", ")}`
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
@@ -714,11 +731,6 @@
|
||||
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);
|
||||
@@ -1100,6 +1112,21 @@
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
// The element pausing is normally user intent, but a stall, a source change,
|
||||
// or a competing controller can also do it — and the pause itself carries no
|
||||
// reason. Log the element state so an unexplained pause/resume loop can be
|
||||
// attributed from an adb capture instead of guessed at.
|
||||
const el = videoElement;
|
||||
console.log(
|
||||
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
||||
` readyState=${el?.readyState}` +
|
||||
` networkState=${el?.networkState}` +
|
||||
` seeking=${el?.seeking}` +
|
||||
` ended=${el?.ended}` +
|
||||
` isSeeking=${isSeeking}` +
|
||||
` isBuffering=${isBuffering}` +
|
||||
` handoff=${handoffState.active}`
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
@@ -1143,11 +1170,33 @@
|
||||
const targetTime = parseFloat(input.value);
|
||||
// Update the displayed time immediately for smooth visual feedback
|
||||
currentTime = targetTime;
|
||||
// The user has moved the value; the next release must commit it.
|
||||
seekCommitArmed = true;
|
||||
}
|
||||
|
||||
async function handleSeekBarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const targetTime = parseFloat(input.value);
|
||||
/**
|
||||
* Seek-bar released — commit the value the user landed on, at most once.
|
||||
*
|
||||
* Wired to `touchend`/`mouseup` AND `change`, because `change` alone is not
|
||||
* dependable: Android's WebView does not reliably fire it for a touch
|
||||
* interaction on a range input, so the thumb moved to the tapped position but
|
||||
* the seek never ran ("the bar moves, playback doesn't"). Engines that DO fire
|
||||
* `change` deliver both signals, hence the arm/disarm — whichever arrives
|
||||
* first commits and the other is a no-op.
|
||||
*/
|
||||
function handleSeekBarRelease(e: Event) {
|
||||
isDraggingSeekBar = false;
|
||||
if (!seekCommitArmed) return;
|
||||
seekCommitArmed = false;
|
||||
const input = (e.currentTarget ?? e.target) as HTMLInputElement;
|
||||
void commitSeek(parseFloat(input.value));
|
||||
}
|
||||
|
||||
async function commitSeek(rawTarget: number) {
|
||||
// Clamp strictly inside the media: the range input's max IS the duration, so
|
||||
// dragging fully right would otherwise request a segment past the media end,
|
||||
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
|
||||
const targetTime = clampSeekTarget(rawTarget, duration);
|
||||
|
||||
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||
isSeeking = true;
|
||||
@@ -1384,16 +1433,9 @@
|
||||
to: newTime.toFixed(2),
|
||||
});
|
||||
|
||||
// Call the unified handleSeekBarChange logic with the new time
|
||||
// Create a synthetic event to reuse the existing logic
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
value: newTime.toString()
|
||||
}
|
||||
} as unknown as Event;
|
||||
|
||||
// Same commit path as the seek bar — one place decides how a seek is issued.
|
||||
try {
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
await commitSeek(newTime);
|
||||
} finally {
|
||||
// The player is authoritative again from here on.
|
||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||
@@ -1421,8 +1463,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from the touch target collecting the tag/attribute pairs
|
||||
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
|
||||
*/
|
||||
function ancestorChain(target: EventTarget | null) {
|
||||
const chain: Array<{
|
||||
tag: string;
|
||||
isPlayerControls?: boolean;
|
||||
isPlayerSurface?: boolean;
|
||||
}> = [];
|
||||
let node = target as HTMLElement | null;
|
||||
// Bounded walk: controls live a few levels below the player root, and
|
||||
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
|
||||
while (node && node.tagName !== "BODY") {
|
||||
chain.push({
|
||||
tag: node.tagName ?? "",
|
||||
isPlayerControls: node.dataset?.playerControls !== undefined,
|
||||
isPlayerSurface: node.dataset?.playerSurface !== undefined,
|
||||
});
|
||||
node = node.parentElement;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
// Touch gesture handlers
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
// Taps on the controls belong to those controls. This listener is on the
|
||||
// container and touch events bubble, so without this a tap on the bottom
|
||||
// play button would toggle here AND again via the button's own click — the
|
||||
// two cancelling out and leaving the control apparently dead (DR-098).
|
||||
if (isControlSurfaceTouch(ancestorChain(e.target))) {
|
||||
// The move handler must stay out of it too. It reads touchStartX/Y, which
|
||||
// this early return leaves at the PREVIOUS gesture's values, so a seek-bar
|
||||
// drag came out as a huge vertical delta: it was mis-read as a brightness
|
||||
// swipe, which dimmed the screen and fired a spurious play/pause
|
||||
// "correction" mid-drag (DR-098).
|
||||
playerGestureActive = false;
|
||||
return;
|
||||
}
|
||||
playerGestureActive = true;
|
||||
|
||||
const touch = e.touches[0];
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
@@ -1434,28 +1515,29 @@
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
// Suppress the compatibility click this touch will synthesize.
|
||||
lastTouchTapAt = Date.now();
|
||||
|
||||
if (outcome.action === "seek") {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
|
||||
// leaves the play state as it was (playing keeps playing, paused stays
|
||||
// paused).
|
||||
if (outcome.togglePlayPause) togglePlayPause();
|
||||
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);
|
||||
// First tap: act now. Nothing is deferred, so there is no timer to race the
|
||||
// compatibility click Android synthesizes after a touch tap (see DR-098).
|
||||
togglePlayPause();
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
// Only a gesture that began on the bare video surface is ours. Re-checking
|
||||
// the target here would not be enough: the touch that started on a control
|
||||
// never recorded a start point, so any delta computed here is meaningless.
|
||||
if (!playerGestureActive) return;
|
||||
if (!e.touches[0]) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
@@ -1465,14 +1547,16 @@
|
||||
|
||||
// Minimum movement to register as swipe (50px)
|
||||
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;
|
||||
// Only on the frame the gesture is first recognised as a swipe — this runs
|
||||
// on every touchmove, and the correction below must happen exactly once.
|
||||
if (!swipeGestureActive) {
|
||||
// The touchstart already toggled play/pause (taps act immediately now),
|
||||
// so undo it: a swipe must not change the play state. Forget the tap too,
|
||||
// so it cannot pair with a later tap into a spurious seek.
|
||||
togglePlayPause();
|
||||
tapGestures.cancel();
|
||||
}
|
||||
swipeGestureActive = true;
|
||||
|
||||
// Brightness control on vertical swipe
|
||||
swipeType = "brightness";
|
||||
@@ -1486,19 +1570,22 @@
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
playerGestureActive = false;
|
||||
swipeGestureActive = false;
|
||||
swipeType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
|
||||
* `handleTouchStart`, so the compatibility click the browser synthesizes after
|
||||
* a tap must be ignored or every tap toggles twice.
|
||||
*
|
||||
* Used by EVERY click target layered over the video, not just the <video>:
|
||||
* pausing renders the full-screen play overlay, so the synthesized click lands
|
||||
* on that button instead and would re-toggle straight back to playing.
|
||||
*/
|
||||
function handleVideoClick(e: MouseEvent) {
|
||||
// A click synthesized from a touch reports no pointer movement detail.
|
||||
if (e.detail === 0 || tapTimeout !== null) return;
|
||||
function handleSurfaceClick(e: MouseEvent) {
|
||||
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
|
||||
togglePlayPause();
|
||||
}
|
||||
|
||||
@@ -1666,7 +1753,7 @@
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={handleVideoClick}
|
||||
onclick={handleSurfaceClick}
|
||||
>
|
||||
<!-- Temporarily disabled to debug playback issues
|
||||
{#each subtitleTracks() as track}
|
||||
@@ -1759,10 +1846,17 @@
|
||||
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if !isPlaying}
|
||||
<!-- Play/Pause overlay -->
|
||||
<!-- Play overlay. Visually this IS the video surface, so it is marked
|
||||
`data-player-surface`: it must keep participating in tap gestures even
|
||||
though it is a <button>, or the second tap of a double tap (which lands
|
||||
here, because the first tap paused and raised this overlay) is
|
||||
discarded as "a tap on a control" and seeking dies. It still shares the
|
||||
synthesized-click guard, since it appears exactly when a tap pauses.
|
||||
See DR-098. -->
|
||||
<button
|
||||
data-player-surface
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||
onclick={togglePlayPause}
|
||||
onclick={handleSurfaceClick}
|
||||
aria-label="Play"
|
||||
>
|
||||
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -1775,7 +1869,13 @@
|
||||
returned actors for the current timestamp. Tapping an actor with a
|
||||
resolved Jellyfin Person id opens their library page. -->
|
||||
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
|
||||
<div class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto">
|
||||
<!-- Offset by the safe-area insets so the card clears the status bar and,
|
||||
in landscape, the display cutout. (UR-066) -->
|
||||
<div
|
||||
class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto"
|
||||
style:top="calc(1rem + var(--safe-top))"
|
||||
style:right="calc(1rem + var(--safe-right))"
|
||||
>
|
||||
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
|
||||
@@ -1810,9 +1910,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||
container-level tap gestures ignore touches here (see DR-098).
|
||||
|
||||
The video itself deliberately fills the whole screen (edge-to-edge, under
|
||||
the cutout), but every interactive control lives in here — so this box,
|
||||
not the video, carries the safe-area insets. Without them the scrub bar
|
||||
and the close/fullscreen buttons sit under the Android gesture bar, and
|
||||
in landscape under the display cutout. (UR-066) -->
|
||||
<div
|
||||
data-player-controls
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
||||
style:padding-bottom="calc(1rem + var(--safe-bottom))"
|
||||
style:padding-left="calc(1rem + var(--safe-left))"
|
||||
style:padding-right="calc(1rem + var(--safe-right))"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
@@ -1838,11 +1949,11 @@
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarChange}
|
||||
onchange={handleSeekBarRelease}
|
||||
onmousedown={() => isDraggingSeekBar = true}
|
||||
onmouseup={() => isDraggingSeekBar = false}
|
||||
onmouseup={handleSeekBarRelease}
|
||||
ontouchstart={() => isDraggingSeekBar = true}
|
||||
ontouchend={() => isDraggingSeekBar = false}
|
||||
ontouchend={handleSeekBarRelease}
|
||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Behavioural regression tests for the video tap surface — rendered against the
|
||||
* REAL component, not a hand-modelled DOM.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-098 | UT-092
|
||||
*
|
||||
* Why this file exists:
|
||||
*
|
||||
* `tapGestures.test.ts` tests `registerTap` / `isControlSurfaceTouch` /
|
||||
* `isSynthesizedTouchClick` as isolated pure functions. Every one of those tests
|
||||
* passed while, on the device, in sequence: the player pause-looped, then
|
||||
* pausing became impossible, then the bottom controls went dead, then
|
||||
* double-tap-to-seek stopped working. The helpers were each behaving exactly as
|
||||
* specified — the bugs were all in the *composition*: which element actually
|
||||
* receives a tap once Svelte has re-rendered.
|
||||
*
|
||||
* Testing my own helpers could not catch that, and modelling the DOM by hand in
|
||||
* a test just re-encodes the same wrong assumption. So these tests render
|
||||
* VideoPlayer and dispatch real touch/click events at whatever element is
|
||||
* genuinely on top, asserting user-visible outcomes ("a double tap seeks")
|
||||
* rather than internals.
|
||||
*
|
||||
* The specific traps encoded here, each a bug that shipped:
|
||||
* - pausing renders a full-screen <button> play overlay OVER the video, so the
|
||||
* second tap of a double tap lands on a button, not the video;
|
||||
* - the browser synthesizes a `click` after a touch tap, which must not toggle
|
||||
* a second time, on ANY layered target;
|
||||
* - the bottom controls bar must drive its own buttons and NOT the container's
|
||||
* tap gestures.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { tick } from "svelte";
|
||||
import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||
|
||||
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
|
||||
|
||||
const toggleSpy = vi.fn();
|
||||
const seekVideoSpy = vi.fn();
|
||||
const seekSpy = vi.fn();
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
|
||||
|
||||
vi.mock("$lib/player", () => ({
|
||||
playerController: {
|
||||
toggle: (...a: unknown[]) => {
|
||||
toggleSpy(...a);
|
||||
return Promise.resolve();
|
||||
},
|
||||
seekVideo: (...a: unknown[]) => {
|
||||
seekVideoSpy(...a);
|
||||
return Promise.resolve();
|
||||
},
|
||||
seek: (...a: unknown[]) => {
|
||||
seekSpy(...a);
|
||||
return Promise.resolve();
|
||||
},
|
||||
setActiveAdapter: vi.fn(),
|
||||
clearActiveAdapter: vi.fn(),
|
||||
getActiveAdapter: vi.fn(() => null),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/player/adapters/rustReportHost", () => ({
|
||||
createRustReportHost: () => ({
|
||||
onState: vi.fn(),
|
||||
onPosition: vi.fn(),
|
||||
onMediaLoaded: vi.fn(),
|
||||
onEnded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onStreamUrlChanged: vi.fn(),
|
||||
onBuffering: vi.fn(),
|
||||
onReady: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/player/html5Adapter", () => ({
|
||||
reportState: vi.fn(),
|
||||
reportPosition: vi.fn(),
|
||||
reportMediaLoaded: vi.fn(),
|
||||
resetReporting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||
isPipSupported: () => false,
|
||||
enterPip: vi.fn(),
|
||||
setAutoEnterEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
|
||||
subscribe: (fn: (v: unknown) => void) => {
|
||||
fn({ isAuthenticated: true });
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MEDIA = {
|
||||
id: "item-1",
|
||||
name: "Test Episode",
|
||||
type: "Episode",
|
||||
runTimeTicks: 6_000_000_000, // 600s
|
||||
} as any;
|
||||
|
||||
/** Dispatch a touch at (x, y) on whatever element is topmost there. */
|
||||
function touchAt(el: Element, x: number) {
|
||||
const touch = { clientX: x, clientY: 300 } as Touch;
|
||||
el.dispatchEvent(
|
||||
new TouchEvent("touchstart", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [touch] as unknown as Touch[],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderPlayer() {
|
||||
return render(VideoPlayer, {
|
||||
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
|
||||
});
|
||||
}
|
||||
|
||||
describe("VideoPlayer tap surface (real component)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("a single tap on the video toggles play/pause exactly once", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video");
|
||||
expect(video).toBeTruthy();
|
||||
|
||||
touchAt(video!, 900);
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the synthesized click after a tap does not toggle a second time", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video")!;
|
||||
|
||||
touchAt(video, 900);
|
||||
// The compatibility click the browser fires after a touch tap. detail=0 is
|
||||
// how engines mark it; a late real-detail click is covered by the recency
|
||||
// guard, which this exercises too since it lands immediately.
|
||||
video.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a double tap seeks even though the first tap raised the play overlay", async () => {
|
||||
// THE regression this file exists for. On device the first tap pauses, which
|
||||
// makes Svelte render a full-screen <button> play overlay over the video —
|
||||
// so the SECOND tap lands on a button, not the video. A control-surface
|
||||
// guard that does not know about that overlay discards it and seeking dies.
|
||||
//
|
||||
// Reproducing it requires the overlay to actually render, which means
|
||||
// driving `isPlaying` the way the real element does: via its `pause` event.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video")!;
|
||||
|
||||
// Tap 1 on the video.
|
||||
touchAt(video, 900);
|
||||
|
||||
// The element reports it paused → isPlaying=false → overlay renders.
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
|
||||
const overlay = container.querySelector("[data-player-surface]");
|
||||
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
|
||||
// Tap 2 lands on the OVERLAY, exactly as on device.
|
||||
touchAt(overlay!, 900);
|
||||
|
||||
// Either seek route is acceptable — which one runs depends on whether a
|
||||
// video adapter is registered. What must hold is that a seek happened, to
|
||||
// roughly the forward-skip target.
|
||||
const calls = [...seekVideoSpy.mock.calls, ...seekSpy.mock.calls];
|
||||
expect(calls.length).toBe(1);
|
||||
const [position] = calls[0];
|
||||
expect(position).toBeGreaterThan(0);
|
||||
expect(position).toBeLessThanOrEqual(SEEK_FORWARD_SECONDS);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("tapping the bottom play/pause button toggles once, not twice", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const controls = container.querySelector("[data-player-controls]");
|
||||
expect(controls).toBeTruthy();
|
||||
|
||||
const playBtn = controls!.querySelector("button");
|
||||
expect(playBtn).toBeTruthy();
|
||||
|
||||
// A real press: touchstart bubbles to the container's gesture handler, then
|
||||
// the button's own click fires. Only ONE toggle may result.
|
||||
touchAt(playBtn!, 40);
|
||||
playBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* VideoPlayer seek-bar TOUCH scrub regression tests (Android).
|
||||
*
|
||||
* Reported bug: on Android, dragging the progress bar does not change the
|
||||
* playback location.
|
||||
*
|
||||
* The gesture listener lives on the outer container and touch events bubble.
|
||||
* `handleTouchStart` ignores touches that land on a control (the seek bar is an
|
||||
* <input>, inside `data-player-controls`) — but `handleTouchMove` does not, so a
|
||||
* seek-bar drag is still interpreted as a container swipe. That mis-read swipe
|
||||
* fires `togglePlayPause()` (undoing a first-tap toggle that never happened) and
|
||||
* hijacks the drag into brightness control.
|
||||
*
|
||||
* The existing scrub regression tests only drive the slider with MOUSE events,
|
||||
* which never reach the touch handlers — which is why this survived.
|
||||
*
|
||||
* The seek was also committed only from `change`, which Android's WebView does
|
||||
* not reliably fire for a touch interaction on a range input — so a tap moved
|
||||
* the thumb and no seek ever ran. Release now commits from touchend/mouseup too.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-098, DR-099 | UT-089, UT-090
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
return () => {
|
||||
delete channelHandlers[channel];
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
const playerPlayItem = vi.fn(async () => ({
|
||||
useHtml5Element: false,
|
||||
backend: "exoplayer",
|
||||
state: { kind: "playing" },
|
||||
}));
|
||||
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
||||
strategy: "native",
|
||||
position,
|
||||
}));
|
||||
const playerStop = vi.fn(async () => ({}));
|
||||
const playerToggle = vi.fn(async () => ({ state: "playing" }));
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: {
|
||||
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
|
||||
playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
|
||||
playerStop: (...a: any[]) => playerStop(...(a as [])),
|
||||
playerToggle: (...a: any[]) => playerToggle(...(a as [])),
|
||||
playerPlay: vi.fn(async () => ({})),
|
||||
playerPause: vi.fn(async () => ({})),
|
||||
playerSetSleepTimer: vi.fn(async () => ({})),
|
||||
playerCancelSleepTimer: vi.fn(async () => ({})),
|
||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||
},
|
||||
events: {
|
||||
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
getUserId: () => "user-1",
|
||||
getRepository: () => ({
|
||||
getHandle: () => "repo-1",
|
||||
getSubtitleUrl: async () => "",
|
||||
jrayActorsAt: async () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$app/navigation", () => ({
|
||||
goto: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
||||
import { tick } from "svelte";
|
||||
import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
function makeEpisode(): MediaItem {
|
||||
return {
|
||||
id: "ep1",
|
||||
name: "Episode 1",
|
||||
kind: "episode",
|
||||
durationMs: 24 * 60 * 1000, // 24 min
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
async function mountAndroidPlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||
mediaSourceId: "src-1",
|
||||
needsTranscoding: false,
|
||||
onClose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
return { ...utils, slider, video };
|
||||
}
|
||||
|
||||
function touch(x: number, y: number) {
|
||||
return { clientX: x, clientY: y } as Touch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the seek bar with TOUCH events, the way a finger does on Android.
|
||||
*
|
||||
* A real drag along the bar moves the finger far enough that the container's
|
||||
* swipe detector (50px) would trigger if it were still listening.
|
||||
*/
|
||||
async function touchScrubTo(
|
||||
slider: HTMLInputElement,
|
||||
video: HTMLVideoElement,
|
||||
target: number
|
||||
) {
|
||||
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
||||
// drag; the horizontal travel is what matters.
|
||||
await fireEvent.touchMove(slider, { touches: [touch(400, 690)] });
|
||||
slider.value = String(target);
|
||||
await fireEvent.input(slider);
|
||||
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
|
||||
await fireEvent.change(slider);
|
||||
await fireEvent.touchEnd(slider, { touches: [] });
|
||||
if (video) await fireEvent(video, new Event("seeked"));
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||
});
|
||||
|
||||
it("a touch drag on the seek bar seeks to the dragged position", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("a touch drag on the seek bar never toggles play/pause", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
// The container gesture layer must stay out of a control drag entirely:
|
||||
// no swipe mis-read, so no play/pause correction.
|
||||
expect(playerToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits the seek on touchend even when the engine never fires `change`", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
|
||||
// Android's WebView does not reliably fire `change` for a touch interaction
|
||||
// on a range input. A tap on the track still moves the thumb and fires
|
||||
// `input` — the seek must be committed on release regardless.
|
||||
await fireEvent.touchStart(slider, { touches: [touch(400, 700)] });
|
||||
slider.value = "600";
|
||||
await fireEvent.input(slider);
|
||||
await fireEvent.touchEnd(slider, { touches: [] });
|
||||
if (video) await fireEvent(video, new Event("seeked"));
|
||||
await tick();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the seek exactly once when both touchend and change fire", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
|
||||
const { slider, video, container } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
// Brightness is applied as a CSS filter on the <video>; a control drag must
|
||||
// leave it untouched.
|
||||
const el = container.querySelector("video") as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
expect(el.style.filter).toBe("brightness(1)");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Regression tests for the `/player/[id]` surface decision.
|
||||
*
|
||||
* The bug these pin down: a video that was left and re-entered rendered in the
|
||||
* AUDIO player. Exiting a webview-rendered video does not stop the Rust
|
||||
* controller (`onReportStop` deliberately emits no `stopped` state, so the
|
||||
* autoplay handoff survives), so the backend still reports that episode/movie as
|
||||
* the loaded media. Re-entering the route therefore took the "already playing,
|
||||
* just show the UI" shortcut, which returns *before* a stream URL is fetched —
|
||||
* and the render then fell through to `<AudioPlayer>` because it treated
|
||||
* "video without a stream URL" as audio.
|
||||
*
|
||||
* TRACES: UR-005 | DR-100 | UT-092, UT-093
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldReuseActivePlayback, resolvePlayerSurface } from "./playerSurface";
|
||||
|
||||
describe("shouldReuseActivePlayback", () => {
|
||||
it("reuses playback when the same audio track is already loaded", () => {
|
||||
expect(
|
||||
shouldReuseActivePlayback({
|
||||
requestedId: "track-1",
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT reuse playback for video, even when the backend reports it loaded", () => {
|
||||
// Video needs a full load: the shortcut skips fetching the stream URL, and
|
||||
// <VideoPlayer> cannot render without one.
|
||||
expect(
|
||||
shouldReuseActivePlayback({
|
||||
requestedId: "episode-1",
|
||||
activeMediaId: "episode-1",
|
||||
isVideo: true,
|
||||
forceRestart: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse playback for a different item", () => {
|
||||
expect(
|
||||
shouldReuseActivePlayback({
|
||||
requestedId: "track-2",
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse playback when nothing is loaded", () => {
|
||||
expect(
|
||||
shouldReuseActivePlayback({
|
||||
requestedId: "track-1",
|
||||
activeMediaId: null,
|
||||
isVideo: false,
|
||||
forceRestart: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse playback when an explicit start position is requested", () => {
|
||||
expect(
|
||||
shouldReuseActivePlayback({
|
||||
requestedId: "track-1",
|
||||
activeMediaId: "track-1",
|
||||
isVideo: false,
|
||||
startPosition: 42,
|
||||
forceRestart: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse playback when restarting (next-episode advance)", () => {
|
||||
expect(
|
||||
shouldReuseActivePlayback({
|
||||
requestedId: "episode-2",
|
||||
activeMediaId: "episode-2",
|
||||
isVideo: true,
|
||||
forceRestart: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePlayerSurface", () => {
|
||||
it("renders the video surface for video with a stream URL", () => {
|
||||
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe(
|
||||
"video"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the audio surface for audio content", () => {
|
||||
expect(resolvePlayerSurface({ isVideo: false, streamUrl: null })).toBe("audio");
|
||||
});
|
||||
|
||||
it("never renders video content in the audio surface when the stream URL is missing", () => {
|
||||
// A video whose stream URL has not resolved yet is pending, not audio —
|
||||
// otherwise the movie/episode shows up in the audio player.
|
||||
expect(resolvePlayerSurface({ isVideo: true, streamUrl: null })).toBe("pending");
|
||||
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "" })).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Pure decisions for the `/player/[id]` route: which player surface to render,
|
||||
* and whether a load can be skipped because the backend is already playing the
|
||||
* requested item.
|
||||
*
|
||||
* Kept free of Svelte so both can be unit-tested without mounting the route.
|
||||
*
|
||||
* TRACES: UR-005 | DR-100 | UT-092, UT-093
|
||||
*/
|
||||
|
||||
/** Which player component the route should render. */
|
||||
export type PlayerSurface = "video" | "audio" | "pending";
|
||||
|
||||
export interface ReuseActivePlaybackInput {
|
||||
/** Item id the route was asked to play. */
|
||||
requestedId: string;
|
||||
/** Id of the media the backend currently reports as loaded, if any. */
|
||||
activeMediaId: string | null | undefined;
|
||||
/** Whether the requested item is video content. */
|
||||
isVideo: boolean;
|
||||
/** Explicit start position, if the caller asked for one. */
|
||||
startPosition?: number;
|
||||
/** Advancing to a next episode always restarts from the beginning. */
|
||||
forceRestart: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the route can show its UI over the backend's existing playback
|
||||
* instead of reloading the item (e.g. expanding the audio mini player).
|
||||
*
|
||||
* Never for video. The shortcut returns before a stream URL is fetched, which
|
||||
* is fine for audio (the backend owns the stream and the UI only mirrors it)
|
||||
* but leaves `<VideoPlayer>` with nothing to render. Leaving a webview-rendered
|
||||
* video does not clear the Rust controller's media — closing the route emits no
|
||||
* `stopped` state by design — so re-entering the same movie/episode hit this
|
||||
* shortcut and rendered the audio player instead.
|
||||
*/
|
||||
export function shouldReuseActivePlayback(input: ReuseActivePlaybackInput): boolean {
|
||||
return (
|
||||
!input.isVideo &&
|
||||
input.activeMediaId === input.requestedId &&
|
||||
!input.startPosition &&
|
||||
!input.forceRestart
|
||||
);
|
||||
}
|
||||
|
||||
export interface PlayerSurfaceInput {
|
||||
isVideo: boolean;
|
||||
streamUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which surface to render for the loaded item.
|
||||
*
|
||||
* Video without a stream URL is `pending`, never `audio` — falling through to
|
||||
* the audio player is how a movie/episode ended up in it.
|
||||
*/
|
||||
export function resolvePlayerSurface(input: PlayerSurfaceInput): PlayerSurface {
|
||||
if (input.isVideo) {
|
||||
return input.streamUrl ? "video" : "pending";
|
||||
}
|
||||
return "audio";
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
clampSeekTarget,
|
||||
END_SEEK_MARGIN_SECONDS,
|
||||
isSynthesizedTouchClick,
|
||||
isControlSurfaceTouch,
|
||||
TOUCH_CLICK_SUPPRESS_MS,
|
||||
} from "./tapGestures";
|
||||
|
||||
const SCREEN_WIDTH = 1000;
|
||||
@@ -25,24 +30,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
|
||||
}
|
||||
|
||||
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);
|
||||
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
|
||||
//
|
||||
// 1st tap: toggle play/pause
|
||||
// 2nd tap: seek, then toggle play/pause AGAIN
|
||||
//
|
||||
// The second toggle undoes the first, so a double tap seeks while leaving the
|
||||
// play state exactly as it was: playing -> jump and keep playing; paused ->
|
||||
// jump and stay paused. The old design deferred the first tap behind a 300ms
|
||||
// timer, which raced the synthesized click and produced a pause/unpause loop.
|
||||
|
||||
// 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("toggles play/pause immediately on the first tap", () => {
|
||||
const state = createTapGestureState();
|
||||
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const second = asSeek(tap(state, RIGHT, 1150));
|
||||
@@ -50,12 +53,11 @@ describe("tap gesture resolution", () => {
|
||||
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();
|
||||
// The re-toggle is what preserves the play state across a double tap.
|
||||
expect(second.togglePlayPause).toBe(true);
|
||||
});
|
||||
|
||||
it("seeks back 10s on a double tap on the left half", () => {
|
||||
it("seeks back 10s AND toggles again on a second left-side tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, LEFT, 1000);
|
||||
const second = asSeek(tap(state, LEFT, 1100));
|
||||
@@ -63,24 +65,44 @@ describe("tap gesture resolution", () => {
|
||||
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||
expect(second.seekSeconds).toBe(-10);
|
||||
expect(second.feedback).toBe("left");
|
||||
expect(second.togglePlayPause).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a second tap after the window as a new pending single tap", () => {
|
||||
it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
|
||||
const state = createTapGestureState();
|
||||
let playing = true;
|
||||
const apply = (outcome: ReturnType<typeof tap>) => {
|
||||
if (outcome.action === "togglePlayPause") playing = !playing;
|
||||
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
|
||||
};
|
||||
|
||||
apply(tap(state, RIGHT, 1000)); // toggle -> paused
|
||||
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
|
||||
expect(playing).toBe(true);
|
||||
|
||||
// And from paused, a double tap leaves it paused.
|
||||
playing = false;
|
||||
apply(tap(state, RIGHT, 2000));
|
||||
apply(tap(state, RIGHT, 2100));
|
||||
expect(playing).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a tap after the window as a fresh first tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||
|
||||
expect(late.action).toBe("pending");
|
||||
expect(late.action).toBe("togglePlayPause");
|
||||
});
|
||||
|
||||
it("does not treat a third tap as another double tap", () => {
|
||||
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
|
||||
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");
|
||||
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
|
||||
// play/pause — there is no "third tap" concept.
|
||||
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
|
||||
});
|
||||
|
||||
it("accumulates repeated double taps on the same side", () => {
|
||||
@@ -103,12 +125,13 @@ describe("tap gesture resolution", () => {
|
||||
expect(second.feedback).toBe("right");
|
||||
});
|
||||
|
||||
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
state.cancel();
|
||||
|
||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
// Without cancel() this would have been the seeking second tap.
|
||||
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +146,28 @@ describe("seek target resolution", () => {
|
||||
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("clamps short of the duration when skipping past the end", () => {
|
||||
// Never land exactly on `duration`: hls.js would then request the segment
|
||||
// that starts at/after the media end, which the server never produces —
|
||||
// the fetch times out and the gap-controller stalls in a pause loop.
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
|
||||
DURATION - END_SEEK_MARGIN_SECONDS
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
|
||||
// Regression: seeking near the end of a ~105min transcoded item clamped to
|
||||
// the exact runtime (6330.324s), making hls.js fetch segment 1055 which
|
||||
// starts at 6336.33s — past the end. That segment 404s/times out forever.
|
||||
const runtime = 6330.324;
|
||||
const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });
|
||||
|
||||
expect(target).toBeLessThan(runtime);
|
||||
expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
|
||||
});
|
||||
|
||||
it("does not clamp below zero for media shorter than the end margin", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
|
||||
});
|
||||
|
||||
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||
@@ -156,3 +199,78 @@ describe("seek target resolution", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control-surface touches are not gestures", () => {
|
||||
// Regression: the gesture listener is on the outer container and touch events
|
||||
// bubble, so tapping the bottom play/pause button ran the gesture handler
|
||||
// (toggle #1) AND the button's own click handler (toggle #2). The two
|
||||
// cancelled out and the control appeared dead.
|
||||
it("treats a tap on a button as a control, not a gesture", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the seek bar input as a control", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats anything inside the controls bar as a control", () => {
|
||||
expect(
|
||||
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a tap on the bare video surface through as a gesture", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive about tag names", () => {
|
||||
expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesized touch-click suppression", () => {
|
||||
// Regression: pausing renders a full-screen play-overlay button over the
|
||||
// video, so the compatibility click Android synthesizes from the tap lands on
|
||||
// the OVERLAY, not the <video>. With no guard there it re-toggled and undid
|
||||
// the pause — pausing looked impossible while unpausing worked fine (the
|
||||
// overlay is removed when playing, so nothing intercepted that direction).
|
||||
it("suppresses a click with detail 0 (clearly synthesized)", () => {
|
||||
expect(isSynthesizedTouchClick(0, 10_000, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a real-detail click that closely follows a touch tap", () => {
|
||||
const tapAt = 10_000;
|
||||
expect(isSynthesizedTouchClick(1, tapAt + 120, tapAt)).toBe(true);
|
||||
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS - 1, tapAt)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a genuine mouse click well after any touch", () => {
|
||||
const tapAt = 10_000;
|
||||
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS + 1, tapAt)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a genuine mouse click when no touch has ever happened", () => {
|
||||
expect(isSynthesizedTouchClick(1, 10_000, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seek target clamping (shared by skip and seek-bar drag)", () => {
|
||||
it("keeps a mid-stream target untouched", () => {
|
||||
expect(clampSeekTarget(100, 600)).toBe(100);
|
||||
});
|
||||
|
||||
it("pulls a drag to the very end back inside the media", () => {
|
||||
// The seek bar's max IS the duration, so dragging fully right yields
|
||||
// exactly `duration` — the value that triggers the dead-segment stall.
|
||||
expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
|
||||
});
|
||||
|
||||
it("clamps negative and non-finite targets to zero", () => {
|
||||
expect(clampSeekTarget(-5, 600)).toBe(0);
|
||||
expect(clampSeekTarget(NaN, 600)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the target alone when the duration is unknown", () => {
|
||||
expect(clampSeekTarget(500, 0)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
/**
|
||||
* 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.
|
||||
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
|
||||
* deferral:
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||
* 1st tap: toggle play/pause
|
||||
* 2nd tap (within the window): seek, then toggle play/pause AGAIN
|
||||
*
|
||||
* The second toggle undoes the first, so a double tap seeks while leaving the
|
||||
* play state exactly as it started — playing stays playing, paused stays paused.
|
||||
*
|
||||
* This replaced a design that deferred the first tap behind a 300ms timer so it
|
||||
* could be cancelled if a second tap arrived. That deferral raced the
|
||||
* compatibility `click` Android's WebView synthesizes after a touch tap: the
|
||||
* timer cleared its own handle *before* running the toggle, reopening the guard
|
||||
* that was meant to suppress the late click, which then toggled a second time.
|
||||
* The result was a play/pause loop about a second apart. Acting immediately
|
||||
* removes the timer, the window race, and the loop.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
|
||||
*/
|
||||
|
||||
/** A second tap within this window makes a double tap. */
|
||||
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
|
||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||
|
||||
/**
|
||||
* How long after a touch tap a mouse `click` is assumed to be the compatibility
|
||||
* event the browser synthesizes from that touch. Android's WebView can deliver it
|
||||
* noticeably late, so this is generous.
|
||||
*/
|
||||
export const TOUCH_CLICK_SUPPRESS_MS = 700;
|
||||
|
||||
/**
|
||||
* Whether a touch landed on an interactive control rather than the bare video
|
||||
* surface, and so must NOT be interpreted as a play/pause or seek gesture.
|
||||
*
|
||||
* The gesture listener sits on the outer container, and touch events bubble, so
|
||||
* without this a tap on the bottom control bar runs the gesture handler (toggle
|
||||
* #1) *and* the button's own click handler (toggle #2) — the two cancel out and
|
||||
* the button appears dead. Buttons, links, inputs (the seek bar), and anything
|
||||
* inside an element marked `data-player-controls` are treated as controls.
|
||||
*
|
||||
* Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
|
||||
* testable without a DOM.
|
||||
*/
|
||||
export function isControlSurfaceTouch(
|
||||
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
|
||||
): boolean {
|
||||
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
|
||||
for (const node of ancestors) {
|
||||
// `data-player-surface` wins over the tag check: the full-screen play overlay
|
||||
// is a <button> but is visually the video itself, and must keep taking tap
|
||||
// gestures — otherwise the second tap of a double tap (which lands on it,
|
||||
// because the first tap paused and raised it) is discarded and seeking dies.
|
||||
if (node.isPlayerSurface === true) return false;
|
||||
if (node.isPlayerControls === true) return true;
|
||||
if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `click` should be ignored because a touch tap already handled it.
|
||||
*
|
||||
* EVERY click target layered over the video must consult this — not just the
|
||||
* `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
|
||||
* synthesized click lands on *that* button rather than the video, and an
|
||||
* unguarded handler there re-toggles and undoes the pause (pause appeared
|
||||
* impossible while unpause worked, because unpausing removes the overlay).
|
||||
*
|
||||
* `detail === 0` catches the synthesized click on engines that report it; the
|
||||
* recency check covers engines that report a real `detail`.
|
||||
*/
|
||||
export function isSynthesizedTouchClick(
|
||||
detail: number,
|
||||
now: number,
|
||||
lastTouchTapAt: number
|
||||
): boolean {
|
||||
if (detail === 0) return true;
|
||||
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
|
||||
}
|
||||
|
||||
/** Double tap on the right half: skip forward. */
|
||||
export const SEEK_FORWARD_SECONDS = 30;
|
||||
|
||||
@@ -22,9 +90,18 @@ 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 };
|
||||
/** First tap: toggle play/pause right now. */
|
||||
| { action: "togglePlayPause" }
|
||||
/**
|
||||
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
|
||||
* undone and the play state survives the double tap unchanged.
|
||||
*/
|
||||
| {
|
||||
action: "seek";
|
||||
seekSeconds: number;
|
||||
feedback: TapFeedback;
|
||||
togglePlayPause: true;
|
||||
};
|
||||
|
||||
export interface TapInput {
|
||||
/** Tap x position, viewport pixels. */
|
||||
@@ -35,32 +112,20 @@ export interface TapInput {
|
||||
|
||||
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).
|
||||
* Forget the previous tap, so the next one is treated as a first tap. Used
|
||||
* when the gesture turns out to be a swipe.
|
||||
*/
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -68,27 +133,64 @@ export function createTapGestureState(): TapGestureState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Classify a tap and return the action to perform *now*.
|
||||
*
|
||||
* A tap that closely follows another is the second of a pair: it seeks and
|
||||
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
|
||||
* first tap and simply toggles. Nothing is deferred, so there is no window to
|
||||
* race and no third-tap case — a consumed pair resets the state.
|
||||
*/
|
||||
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
|
||||
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
|
||||
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" };
|
||||
? {
|
||||
action: "seek",
|
||||
seekSeconds: SEEK_BACKWARD_SECONDS,
|
||||
feedback: "left",
|
||||
togglePlayPause: true,
|
||||
}
|
||||
: {
|
||||
action: "seek",
|
||||
seekSeconds: SEEK_FORWARD_SECONDS,
|
||||
feedback: "right",
|
||||
togglePlayPause: true,
|
||||
};
|
||||
}
|
||||
|
||||
s.lastTapTime = input.now;
|
||||
s.pendingSince = input.now;
|
||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||
return { action: "togglePlayPause" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety margin (seconds) kept between a clamped seek target and the media end.
|
||||
*
|
||||
* Landing *exactly* on `duration` makes hls.js request the segment whose start
|
||||
* time is at/after the end of the media. The server never produces that segment,
|
||||
* so the fetch times out and hls.js' gap-controller stalls forever at the last
|
||||
* buffered position — surfacing as "unpausing bounces straight back to paused".
|
||||
* One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
|
||||
* the final segment boundary.
|
||||
*/
|
||||
export const END_SEEK_MARGIN_SECONDS = 6;
|
||||
|
||||
/**
|
||||
* Clamp an absolute seek target into the safely-playable range.
|
||||
*
|
||||
* Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
|
||||
* drag path, which can otherwise land exactly on `duration` because the range
|
||||
* input's `max` is the duration itself.
|
||||
*/
|
||||
export function clampSeekTarget(target: number, duration: number): number {
|
||||
if (!Number.isFinite(target) || target < 0) return 0;
|
||||
if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
|
||||
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface SeekTargetInput {
|
||||
@@ -123,6 +225,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
|
||||
const target = base + delta;
|
||||
if (target < 0) return 0;
|
||||
if (duration > 0 && target > duration) return duration;
|
||||
// Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
|
||||
// going negative on media shorter than the margin itself.
|
||||
if (duration > 0 && target > duration) {
|
||||
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||
// still trying to play — so it must not be surfaced as a player error, or the
|
||||
// UI reports failure ~once a second for the whole stall.
|
||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||
const abort = new DOMException(
|
||||
"The play() request was interrupted by a call to pause().",
|
||||
"AbortError"
|
||||
);
|
||||
video.play = vi.fn(async () => {
|
||||
throw abort;
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("play() still reports a genuine failure", async () => {
|
||||
video.play = vi.fn(async () => {
|
||||
throw new DOMException("no supported source", "NotSupportedError");
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||
});
|
||||
|
||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||
// element.play() calls is what generates the AbortError storm.
|
||||
let resolvePlay: () => void = () => {};
|
||||
video.play = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolvePlay = () => {
|
||||
video.paused = false;
|
||||
r();
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const first = adapter.play();
|
||||
const second = adapter.play();
|
||||
resolvePlay();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("play() works again after a previous attempt settled", async () => {
|
||||
await adapter.play();
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("pause() calls element.pause()", async () => {
|
||||
video.paused = false;
|
||||
await adapter.pause();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* intents flowing through the PlayerAdapter interface while preserving the
|
||||
* hard-won element behavior verbatim.
|
||||
*
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
||||
*/
|
||||
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
||||
getMediaSourceId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
||||
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
||||
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
||||
* produces it routinely, so it must not reach the player's error channel.
|
||||
*/
|
||||
function isPlayInterruptedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const { name, message } = err as { name?: string; message?: string };
|
||||
return name === "AbortError" || (message ?? "").includes("interrupted");
|
||||
}
|
||||
|
||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
|
||||
private attachedElement: HTMLVideoElement | null = null;
|
||||
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
||||
private pendingPlay: Promise<void> | null = null;
|
||||
private host: AdapterHost;
|
||||
private bridge: Html5ElementBridge;
|
||||
|
||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
async play(): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
||||
// gap-controller recovery path can both ask to play; stacking element.play()
|
||||
// calls is what turns one stall into an AbortError storm.
|
||||
if (this.pendingPlay) return this.pendingPlay;
|
||||
|
||||
this.pendingPlay = (async () => {
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
// A play() aborted by a pause() is transient, not a failure: hls.js
|
||||
// nudges the element to recover from a stall, which cancels the pending
|
||||
// play promise while the element keeps trying. Surfacing it would report
|
||||
// an error roughly once a second for the duration of the stall.
|
||||
if (isPlayInterruptedError(err)) {
|
||||
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
|
||||
} else {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
this.pendingPlay = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.pendingPlay;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
|
||||
+15
-4
@@ -12,7 +12,7 @@
|
||||
* derived + merged (remote-session-aware) stores so UI can import state and
|
||||
* actions from one place, in both local and remote modes.
|
||||
*
|
||||
* TRACES: UR-005 | DR-001, DR-009
|
||||
* TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-091
|
||||
*/
|
||||
|
||||
import { get } from "svelte/store";
|
||||
@@ -83,18 +83,29 @@ function requireHandle(): string {
|
||||
// Transport controls (no repository handle required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Transport intents ALWAYS go to the backend, in both native and HTML5 modes.
|
||||
//
|
||||
// These used to short-circuit into the active video adapter, which made the
|
||||
// webview the decider: `adapter.toggle()` read `el.paused` off the DOM and
|
||||
// flipped the element, so Rust never saw the intent. `el.paused` flips
|
||||
// transiently while an element buffers or settles a seek, so two intents
|
||||
// ~150ms apart could read different values and take opposing actions — a
|
||||
// self-sustaining play/pause loop.
|
||||
//
|
||||
// Now Rust decides from PlayerController state and drives the element back
|
||||
// through a `ControlCommand` event (handled in playerEvents.ts), the same
|
||||
// "backend decides, adapter executes the primitive" split used by
|
||||
// player_seek_video. Do NOT reintroduce an adapter short-circuit here.
|
||||
|
||||
async function play() {
|
||||
if (activeAdapter) return void (await activeAdapter.play());
|
||||
await commands.playerPlay();
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
if (activeAdapter) return void (await activeAdapter.pause());
|
||||
await commands.playerPause();
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (activeAdapter) return void (await activeAdapter.toggle());
|
||||
await commands.playerToggle();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveVideoSource } from "./localSource";
|
||||
|
||||
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
|
||||
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
|
||||
|
||||
describe("resolveVideoSource", () => {
|
||||
it("plays the downloaded file when one exists", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: "/home/u/.local/share/jellytau/movie.mp4",
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.isLocal).toBe(true);
|
||||
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
|
||||
});
|
||||
|
||||
it("never marks a local file as needing transcoding, even when the remote did", () => {
|
||||
// The transcoded path re-requests a whole new stream URL on every seek.
|
||||
// A local file seeks natively; sending it down that route would ask the
|
||||
// server for a stream we deliberately avoided.
|
||||
const decision = resolveVideoSource({
|
||||
localPath: "/downloads/film.mkv",
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.needsTranscoding).toBe(false);
|
||||
});
|
||||
|
||||
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: null,
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
url: "https://server/Videos/abc/master.m3u8",
|
||||
needsTranscoding: true,
|
||||
isLocal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("streams a direct-play remote without claiming it transcodes", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: null,
|
||||
remoteUrl: "https://server/Videos/abc/stream.mp4",
|
||||
remoteNeedsTranscoding: false,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.needsTranscoding).toBe(false);
|
||||
expect(decision.isLocal).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
|
||||
for (const localPath of ["", " "]) {
|
||||
const decision = resolveVideoSource({
|
||||
localPath,
|
||||
remoteUrl: "https://server/stream",
|
||||
remoteNeedsTranscoding: false,
|
||||
toAssetUrl,
|
||||
});
|
||||
expect(decision.isLocal).toBe(false);
|
||||
expect(decision.url).toBe("https://server/stream");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Choosing between a downloaded file and a server stream for video playback.
|
||||
*
|
||||
* Audio has preferred local files since the queue is built (the Rust queue
|
||||
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
|
||||
* and never consults `downloads` — so a downloaded film was streamed anyway,
|
||||
* spending bandwidth that had already been spent and failing outright offline.
|
||||
*
|
||||
* Pure so it can be unit-tested: the component only supplies the two inputs and
|
||||
* the asset-URL converter.
|
||||
*
|
||||
* TRACES: UR-071 | DR-123 | UT-118
|
||||
*/
|
||||
|
||||
export interface VideoSourceInputs {
|
||||
/** Absolute on-disk path of a completed download, or null to stream. */
|
||||
localPath: string | null;
|
||||
/** Stream URL the repository resolved (already transcoded if it had to be). */
|
||||
remoteUrl: string;
|
||||
/** Whether the *remote* stream is a transcode. */
|
||||
remoteNeedsTranscoding: boolean;
|
||||
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
|
||||
toAssetUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface VideoSourceDecision {
|
||||
/** What to hand the `<video>` element. */
|
||||
url: string;
|
||||
/**
|
||||
* Local files are never transcodes, so this is always false for them. It
|
||||
* matters because the transcoded path re-requests a whole new stream URL on
|
||||
* every seek; a local file seeks natively and must not go down that route.
|
||||
*/
|
||||
needsTranscoding: boolean;
|
||||
/** True when playing from disk — for logging and the offline badge. */
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
|
||||
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
|
||||
|
||||
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
|
||||
// not produce an asset URL pointing at nothing.
|
||||
if (localPath && localPath.trim() !== "") {
|
||||
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
|
||||
}
|
||||
|
||||
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
|
||||
*
|
||||
* TRACES: UR-005 | DR-097 | UT-091
|
||||
*
|
||||
* The frontend used to short-circuit transport controls whenever a video adapter
|
||||
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element
|
||||
* directly, so the Rust `PlayerController` never saw the intent and could not
|
||||
* serialise competing ones. Because `el.paused` flips transiently while an HTML5
|
||||
* element buffers or settles a seek, two intents arriving ~150ms apart could read
|
||||
* *different* values and perform *opposing* actions — one playing, one pausing —
|
||||
* which is the self-sustaining play/pause loop observed on Android.
|
||||
*
|
||||
* The rule these tests pin: a transport intent always reaches the backend. Rust
|
||||
* decides play-vs-pause from controller state and drives the webview element back
|
||||
* through a ControlCommand event (the same "backend decides, adapter executes"
|
||||
* split `player_seek_video` already uses).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockCommands = {
|
||||
playerPlay: vi.fn(async () => ({})),
|
||||
playerPause: vi.fn(async () => ({})),
|
||||
playerToggle: vi.fn(async () => ({})),
|
||||
playerStop: vi.fn(async () => ({})),
|
||||
};
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: mockCommands,
|
||||
// Stores pulled in transitively subscribe to typed events at module load.
|
||||
events: {
|
||||
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||
downloadEvent: { listen: vi.fn(async () => () => {}) },
|
||||
searchEvent: { listen: vi.fn(async () => () => {}) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
subscribe: (fn: (v: unknown) => void) => {
|
||||
fn({ isAuthenticated: true });
|
||||
return () => {};
|
||||
},
|
||||
getRepository: () => ({ getHandle: () => "handle-1" }),
|
||||
},
|
||||
}));
|
||||
|
||||
/** A video adapter that records whether the facade reached into it directly. */
|
||||
function makeAdapter() {
|
||||
return {
|
||||
kind: "html5" as const,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(async () => {}),
|
||||
toggle: vi.fn(async () => true),
|
||||
seekElement: vi.fn(async () => {}),
|
||||
reloadSource: vi.fn(async () => {}),
|
||||
attach: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
setVolume: vi.fn(),
|
||||
setMuted: vi.fn(),
|
||||
selectSubtitle: vi.fn(async () => {}),
|
||||
getPosition: vi.fn(() => 0),
|
||||
load: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("transport authority lives in Rust", () => {
|
||||
let playerController: any;
|
||||
let adapter: ReturnType<typeof makeAdapter>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
({ playerController } = await import("./index"));
|
||||
adapter = makeAdapter();
|
||||
playerController.setActiveAdapter(adapter);
|
||||
});
|
||||
|
||||
it("routes toggle to the backend even when a video adapter is active", async () => {
|
||||
await playerController.toggle();
|
||||
|
||||
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||
// The webview must NOT decide play-vs-pause from the DOM.
|
||||
expect(adapter.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes play to the backend even when a video adapter is active", async () => {
|
||||
await playerController.play();
|
||||
|
||||
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes pause to the backend even when a video adapter is active", async () => {
|
||||
await playerController.pause();
|
||||
|
||||
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still routes transport to the backend with no adapter (audio path unchanged)", async () => {
|
||||
playerController.clearActiveAdapter();
|
||||
|
||||
await playerController.toggle();
|
||||
await playerController.play();
|
||||
await playerController.pause();
|
||||
|
||||
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
// Favorites service - Handles toggling favorite status with optimistic updates
|
||||
// TRACES: UR-017 | DR-021
|
||||
// TRACES: UR-017, UR-068 | DR-021, DR-119
|
||||
|
||||
import { get } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isConnected } from "$lib/stores/connectivity";
|
||||
import { setFavorite } from "$lib/stores/favorites";
|
||||
|
||||
/**
|
||||
* Toggle the favorite status of an item.
|
||||
@@ -33,6 +34,10 @@ export async function toggleFavorite(
|
||||
// 1. Update local database first (optimistic update)
|
||||
await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
|
||||
|
||||
// Publish to every mounted view at once, so the heart on a card, the detail
|
||||
// page and the Favourites grid never disagree. TRACES: UR-068 | DR-119
|
||||
setFavorite(itemId, newIsFavorite);
|
||||
|
||||
// 2. Sync to Jellyfin server.
|
||||
//
|
||||
// Only attempt this when we're actually connected. When offline, the server
|
||||
|
||||
@@ -66,9 +66,14 @@ function currentHandle(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every library and cache the full catalog. Best-effort and non-blocking:
|
||||
* safe to call on startup (while online) and on reconnect. No-ops if not
|
||||
* connected or a sync is already running.
|
||||
* Force a full re-index now, ignoring freshness.
|
||||
*
|
||||
* Routine scheduling is the Rust indexer's job (DR-109) — this is the manual
|
||||
* override, for a "re-index now" affordance. It is deliberately *not* called on
|
||||
* startup or reconnect any more: doing so forced a full crawl on every launch
|
||||
* regardless of how fresh the index was.
|
||||
*
|
||||
* The backend refuses overlapping passes, so this is safe to call at any time.
|
||||
*/
|
||||
export async function syncCatalog(): Promise<void> {
|
||||
if (syncInProgress) return;
|
||||
@@ -124,6 +129,8 @@ export async function refreshSyncStatus(): Promise<void> {
|
||||
*/
|
||||
export async function onReconnected(): Promise<void> {
|
||||
await resumeQueued();
|
||||
// Fire-and-forget: don't block reconnection handling on a potentially long walk.
|
||||
void syncCatalog();
|
||||
// Re-indexing on reconnect is the Rust indexer's job (DR-109) — it re-checks
|
||||
// staleness every tick, so it picks this up without a nudge from here. Queued
|
||||
// downloads still need resolving from the frontend, which is why this
|
||||
// function remains.
|
||||
}
|
||||
|
||||
@@ -145,3 +145,76 @@ describe("Player Events — pause must not zero the slider duration", () => {
|
||||
expect(get(playbackDuration)).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A recoverable error is a network hiccup, not the end of playback. The handler
|
||||
* used to stop the player unconditionally, so a blip on wifi killed the track —
|
||||
* on Linux especially, where MPV's EndFile(ERROR) is the only signal a stream
|
||||
* died and there is no in-process controller for the backend to consult.
|
||||
*
|
||||
* TRACES: UR-004, UR-040 | DR-130
|
||||
*/
|
||||
describe("Player Events — recoverable errors get one chance before stopping", () => {
|
||||
beforeEach(async () => {
|
||||
const { cleanupPlayerEvents } = await import("./playerEvents");
|
||||
const { player } = await import("$lib/stores/player");
|
||||
cleanupPlayerEvents();
|
||||
player.setIdle();
|
||||
vi.clearAllMocks();
|
||||
registeredHandler = null;
|
||||
currentQueueItemStore.set(null);
|
||||
});
|
||||
|
||||
it("does not stop the player when Rust re-opened the stream", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
|
||||
cmd === "player_recover_stream" ? true : null
|
||||
);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
const { player } = await import("$lib/stores/player");
|
||||
await initPlayerEvents();
|
||||
await fire({ type: "state_changed", state: "playing", media_id: "track-1" });
|
||||
|
||||
await fire({ type: "error", message: "Playback stream failed", recoverable: true });
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
|
||||
expect(calls).toContain("player_recover_stream");
|
||||
expect(calls).not.toContain("player_stop");
|
||||
expect(get(player).state.kind).not.toBe("error");
|
||||
});
|
||||
|
||||
it("stops the player when recovery declines", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
|
||||
cmd === "player_recover_stream" ? false : null
|
||||
);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
await initPlayerEvents();
|
||||
|
||||
await fire({ type: "error", message: "Playback stream failed", recoverable: true });
|
||||
await Promise.resolve();
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
|
||||
expect(calls).toContain("player_recover_stream");
|
||||
expect(calls).toContain("player_stop");
|
||||
});
|
||||
|
||||
it("does not attempt recovery for an unrecoverable error", async () => {
|
||||
// Android decides in its JNI callback and reports the errors it already
|
||||
// declined as unrecoverable, so this must not ask a second time.
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
await initPlayerEvents();
|
||||
|
||||
await fire({ type: "error", message: "Decoder failed", recoverable: false });
|
||||
await Promise.resolve();
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
|
||||
expect(calls).not.toContain("player_recover_stream");
|
||||
expect(calls).toContain("player_stop");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* frontend stores accordingly. This enables push-based updates instead
|
||||
* of polling.
|
||||
*
|
||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047, DR-097
|
||||
*/
|
||||
|
||||
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -276,9 +276,32 @@ async function handlePlaybackEnded(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Handle error events.
|
||||
*
|
||||
* A recoverable error gets one chance at recovery before anything is torn down.
|
||||
* Backends whose event thread cannot reach the controller (MpvBackend is built
|
||||
* before PlayerController exists) report the failure and rely on this echo to
|
||||
* put the decision back in Rust — the same shape as PlaybackEnded →
|
||||
* playerOnPlaybackEnded. Nothing is decided here: if Rust re-opened the stream
|
||||
* it says so, and stopping the player would kill the playback it just restored.
|
||||
*
|
||||
* TRACES: UR-004, UR-040 | DR-130
|
||||
*/
|
||||
async function handleError(message: string, recoverable: boolean): Promise<void> {
|
||||
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
||||
|
||||
if (recoverable) {
|
||||
try {
|
||||
if (await commands.playerRecoverStream()) {
|
||||
console.log("Stream re-opened after a recoverable error - not stopping");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall through to the normal stop: a failed recovery attempt is still an
|
||||
// error, and leaving the player running would strand it mid-failure.
|
||||
console.error("Stream recovery attempt failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
player.setError(message);
|
||||
|
||||
// Stop backend player to prevent orphaned playback
|
||||
@@ -306,9 +329,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
|
||||
|
||||
/**
|
||||
* Route a backend-originated control command to the active player adapter, so a
|
||||
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
|
||||
* that Rust cannot reach directly. No-op when no video adapter is active (audio
|
||||
* playback is already fully backend-driven).
|
||||
* backend intent can drive the webview <video>/<audio> element that Rust cannot
|
||||
* reach directly. No-op when no adapter is active (native playback is already
|
||||
* fully backend-driven).
|
||||
*
|
||||
* This is the EXECUTION half of transport authority: for webview-rendered media
|
||||
* the Rust controller decides play-vs-pause from the state the element reported
|
||||
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
|
||||
* facade in $lib/player) and come back through this path — never short-circuited
|
||||
* in the webview, which is what caused the DR-097 pause loop.
|
||||
*/
|
||||
function handleControlCommand(action: string, position: number | null): void {
|
||||
const adapter = playerController.getActiveAdapter();
|
||||
|
||||
@@ -17,6 +17,7 @@ function makeConfig(overrides: Partial<CacheConfig> = {}): CacheConfig {
|
||||
albumAffinityThreshold: 0.75,
|
||||
storageLimit: 2 * 1024 * 1024 * 1024,
|
||||
wifiOnly: false,
|
||||
temporaryTtlHours: 24 * 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -177,14 +178,7 @@ describe("preload service", () => {
|
||||
});
|
||||
|
||||
it("should support all config options", async () => {
|
||||
const config = {
|
||||
queuePrecacheEnabled: true,
|
||||
queuePrecacheCount: 5,
|
||||
albumAffinityEnabled: false,
|
||||
albumAffinityThreshold: 0.75,
|
||||
storageLimit: 2 * 1024 * 1024 * 1024,
|
||||
wifiOnly: true,
|
||||
};
|
||||
const config = makeConfig({ wifiOnly: true, albumAffinityEnabled: false });
|
||||
|
||||
await expect(updateCacheConfig(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -84,19 +84,10 @@ class SyncService {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a favorite toggle
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
||||
// Update local state first
|
||||
await commands.storageToggleFavorite(auth.getUserId() ?? "", itemId, isFavorite);
|
||||
|
||||
return this.queueMutation(
|
||||
isFavorite ? "mark_favorite" : "unmark_favorite",
|
||||
itemId
|
||||
);
|
||||
}
|
||||
// NOTE: `queueFavorite` is gone. Favourites are drained by Rust on the
|
||||
// `connectivity:reconnected` signal (DR-120) — the local write already sets
|
||||
// `pending_sync`, and a second queue here would push the same change twice.
|
||||
// See src-tauri/src/commands/favorites.rs.
|
||||
|
||||
/**
|
||||
* Queue playback progress update
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// TRACES: UR-067, UR-068 | DR-117, DR-119 | UT-105, UT-106
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import {
|
||||
favoriteOverrides,
|
||||
setFavorite,
|
||||
clearFavorite,
|
||||
clearAllFavorites,
|
||||
resolveIsFavorite,
|
||||
isFavoriteNow,
|
||||
retainFavorites,
|
||||
} from "./favorites";
|
||||
|
||||
function item(id: string, isFavorite?: boolean): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `Item ${id}`,
|
||||
type: "Movie",
|
||||
kind: "movie",
|
||||
isFolder: false,
|
||||
serverId: "s1",
|
||||
userData: isFavorite === undefined ? undefined : { isFavorite },
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
describe("favorites store", () => {
|
||||
beforeEach(() => clearAllFavorites());
|
||||
|
||||
describe("resolveIsFavorite (UT-105)", () => {
|
||||
it("falls back to the server's userData when nothing was toggled here", () => {
|
||||
expect(resolveIsFavorite(item("a", true), new Map())).toBe(true);
|
||||
expect(resolveIsFavorite(item("a", false), new Map())).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an item with no userData as not favourited", () => {
|
||||
expect(resolveIsFavorite(item("a"), new Map())).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a session override win over userData", () => {
|
||||
// The whole point: after tapping the heart on a card, the item object
|
||||
// still carries the server's stale value until the next fetch.
|
||||
expect(resolveIsFavorite(item("a", false), new Map([["a", true]]))).toBe(true);
|
||||
expect(resolveIsFavorite(item("a", true), new Map([["a", false]]))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a missing item rather than throwing", () => {
|
||||
expect(resolveIsFavorite(null, new Map())).toBe(false);
|
||||
expect(resolveIsFavorite(undefined, new Map())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overrides", () => {
|
||||
it("publishes a toggle to subscribers", () => {
|
||||
setFavorite("a", true);
|
||||
expect(get(favoriteOverrides).get("a")).toBe(true);
|
||||
expect(isFavoriteNow(item("a", false))).toBe(true);
|
||||
|
||||
setFavorite("a", false);
|
||||
expect(isFavoriteNow(item("a", true))).toBe(false);
|
||||
});
|
||||
|
||||
it("clearing an override hands authority back to the item's userData", () => {
|
||||
setFavorite("a", false);
|
||||
expect(isFavoriteNow(item("a", true))).toBe(false);
|
||||
|
||||
clearFavorite("a");
|
||||
expect(isFavoriteNow(item("a", true))).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces the map so Svelte sees a new reference", () => {
|
||||
const before = get(favoriteOverrides);
|
||||
setFavorite("a", true);
|
||||
expect(get(favoriteOverrides)).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retainFavorites (UT-106)", () => {
|
||||
it("drops an item un-favourited during this session", () => {
|
||||
const items = [item("a", true), item("b", true)];
|
||||
const kept = retainFavorites(items, new Map([["a", false]]));
|
||||
expect(kept.map((i) => i.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("keeps everything when nothing was toggled", () => {
|
||||
const items = [item("a", true), item("b", true)];
|
||||
expect(retainFavorites(items, new Map())).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps an item favourited during this session even if the server said otherwise", () => {
|
||||
const items = [item("a", false)];
|
||||
expect(retainFavorites(items, new Map([["a", true]]))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops items the server never marked as favourites", () => {
|
||||
// A listing fetched with a stale scope should not keep non-favourites.
|
||||
expect(retainFavorites([item("a")], new Map())).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Favourites overlay — in-session heart state shared across every surface.
|
||||
//
|
||||
// The durable record lives in Rust (local `user_data` + the Jellyfin server).
|
||||
// This store holds only what the *current session* has changed, so a heart
|
||||
// tapped on a card is reflected on the detail page and the item vanishes from
|
||||
// the Favourites grid without anyone refetching. It is view state, not truth.
|
||||
//
|
||||
// TRACES: UR-068 | DR-119 | UT-105
|
||||
|
||||
import { derived, get, writable } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/** Item id → favourite state set during this session. */
|
||||
const overrides = writable<Map<string, boolean>>(new Map());
|
||||
|
||||
export const favoriteOverrides = { subscribe: overrides.subscribe };
|
||||
|
||||
/**
|
||||
* Record a favourite state locally so every mounted view agrees immediately.
|
||||
* Called by the toggle service after the optimistic local write.
|
||||
*/
|
||||
export function setFavorite(itemId: string, isFavorite: boolean): void {
|
||||
overrides.update((map) => {
|
||||
const next = new Map(map);
|
||||
next.set(itemId, isFavorite);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a session override, so the item's own `userData` is authoritative
|
||||
* again. Used when the backend reports the server's state changed underneath
|
||||
* us (`favorites-changed`) — the fresh fetch that follows carries the truth.
|
||||
*/
|
||||
export function clearFavorite(itemId: string): void {
|
||||
overrides.update((map) => {
|
||||
if (!map.has(itemId)) return map;
|
||||
const next = new Map(map);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAllFavorites(): void {
|
||||
overrides.set(new Map());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution order: a session override wins, then the item's own server-sent
|
||||
* `userData`, then "not favourited".
|
||||
*
|
||||
* The override has to win, or tapping the heart on a card would flip back the
|
||||
* moment the (unchanged) item object re-rendered.
|
||||
*
|
||||
* TRACES: UR-068 | DR-119 | UT-105
|
||||
*/
|
||||
export function resolveIsFavorite(
|
||||
item: Pick<MediaItem, "id" | "userData"> | null | undefined,
|
||||
overrideMap: Map<string, boolean>
|
||||
): boolean {
|
||||
if (!item) return false;
|
||||
const override = overrideMap.get(item.id);
|
||||
if (override !== undefined) return override;
|
||||
return item.userData?.isFavorite ?? false;
|
||||
}
|
||||
|
||||
/** Non-reactive read, for call sites outside a component. */
|
||||
export function isFavoriteNow(item: Pick<MediaItem, "id" | "userData">): boolean {
|
||||
return resolveIsFavorite(item, get(overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the items a listing should no longer show once un-favourited.
|
||||
*
|
||||
* Pure so it can be unit-tested without mounting the page: un-hearting on the
|
||||
* Favourites grid must remove the card, while a *newly* favourited item is
|
||||
* left alone (it belongs to whatever scope the caller fetched).
|
||||
*
|
||||
* TRACES: UR-067 | DR-117, DR-119 | UT-106
|
||||
*/
|
||||
export function retainFavorites<T extends Pick<MediaItem, "id" | "userData">>(
|
||||
items: T[],
|
||||
overrideMap: Map<string, boolean>
|
||||
): T[] {
|
||||
return items.filter((item) => resolveIsFavorite(item, overrideMap));
|
||||
}
|
||||
|
||||
/** Count of items still favourited, for "hide the row when empty" decisions. */
|
||||
export const hasOverrides = derived(overrides, ($o) => $o.size > 0);
|
||||
+23
-1
@@ -1,5 +1,5 @@
|
||||
// Home screen data store - featured items, continue watching, recently added
|
||||
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
|
||||
// TRACES: UR-023, UR-024, UR-034, UR-059, UR-067 | DR-026, DR-027, DR-038, DR-039, DR-089, DR-118
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
@@ -12,6 +12,10 @@ interface HomeState {
|
||||
latestItems: MediaItem[];
|
||||
recentlyPlayedAudio: MediaItem[];
|
||||
resumeMovies: MediaItem[];
|
||||
/** Favourites per scope. Empty rows are not rendered. TRACES: UR-067 | DR-118 */
|
||||
favoriteMovies: MediaItem[];
|
||||
favoriteShows: MediaItem[];
|
||||
favoriteMusic: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -24,6 +28,9 @@ function createHomeStore() {
|
||||
latestItems: [],
|
||||
recentlyPlayedAudio: [],
|
||||
resumeMovies: [],
|
||||
favoriteMovies: [],
|
||||
favoriteShows: [],
|
||||
favoriteMusic: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
@@ -46,6 +53,11 @@ function createHomeStore() {
|
||||
repo.getLatestItems("", 16),
|
||||
repo.getRecentlyPlayedAudio(12), // Backend now handles intelligent grouping
|
||||
repo.getResumeMovies(12),
|
||||
// Favourites, one request per row. The scope is opaque — Rust decides
|
||||
// which item types it covers. TRACES: UR-067 | DR-118
|
||||
repo.getFavorites("movies", { limit: 20 }),
|
||||
repo.getFavorites("tv", { limit: 20 }),
|
||||
repo.getFavorites("music", { limit: 20 }),
|
||||
]);
|
||||
|
||||
const valueOr = <T>(i: number, fallback: T): T =>
|
||||
@@ -60,6 +72,10 @@ function createHomeStore() {
|
||||
const latest = valueOr(2, [] as typeof initialState.latestItems);
|
||||
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
|
||||
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
|
||||
const emptyResult = { items: [] as MediaItem[], totalRecordCount: 0 };
|
||||
const favoriteMovies = valueOr(5, emptyResult).items;
|
||||
const favoriteShows = valueOr(6, emptyResult).items;
|
||||
const favoriteMusic = valueOr(7, emptyResult).items;
|
||||
|
||||
// Use resume items or latest as hero items
|
||||
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);
|
||||
@@ -72,6 +88,9 @@ function createHomeStore() {
|
||||
latestItems: latest,
|
||||
recentlyPlayedAudio: recentAudio,
|
||||
resumeMovies: resumeMovies,
|
||||
favoriteMovies,
|
||||
favoriteShows,
|
||||
favoriteMusic,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -101,4 +120,7 @@ export const nextUpItems = derived(home, $home => $home.nextUpItems);
|
||||
export const latestItems = derived(home, $home => $home.latestItems);
|
||||
export const recentlyPlayedAudio = derived(home, $home => $home.recentlyPlayedAudio);
|
||||
export const resumeMovies = derived(home, $home => $home.resumeMovies);
|
||||
export const favoriteMovies = derived(home, $home => $home.favoriteMovies);
|
||||
export const favoriteShows = derived(home, $home => $home.favoriteShows);
|
||||
export const favoriteMusic = derived(home, $home => $home.favoriteMusic);
|
||||
export const isHomeLoading = derived(home, $home => $home.isLoading);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// TRACES: UR-067 | DR-117
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
FAVORITE_SCOPES,
|
||||
FAVORITE_SCOPE_LABELS,
|
||||
resolveFavoritesScope,
|
||||
favoritesRouteUrl,
|
||||
emptyStateMessage,
|
||||
} from "./favoritesView";
|
||||
|
||||
describe("favoritesView", () => {
|
||||
describe("resolveFavoritesScope", () => {
|
||||
it("round-trips every offered tab", () => {
|
||||
for (const scope of FAVORITE_SCOPES) {
|
||||
expect(resolveFavoritesScope(scope)).toBe(scope);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to All for a missing param", () => {
|
||||
expect(resolveFavoritesScope(null)).toBe("all");
|
||||
expect(resolveFavoritesScope(undefined)).toBe("all");
|
||||
expect(resolveFavoritesScope("")).toBe("all");
|
||||
});
|
||||
|
||||
it("defaults to All for a stale or hand-edited param rather than blanking the page", () => {
|
||||
expect(resolveFavoritesScope("books")).toBe("all");
|
||||
expect(resolveFavoritesScope("MOVIES")).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("favoritesRouteUrl", () => {
|
||||
it("omits the default scope so the base URL stays clean", () => {
|
||||
expect(favoritesRouteUrl("all")).toBe("/library/favorites");
|
||||
});
|
||||
|
||||
it("addresses every other tab explicitly, and round-trips through resolve", () => {
|
||||
for (const scope of FAVORITE_SCOPES) {
|
||||
const url = favoritesRouteUrl(scope);
|
||||
const param = new URL(url, "http://x").searchParams.get("scope");
|
||||
expect(resolveFavoritesScope(param)).toBe(scope);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("labels every scope, using the app's vocabulary rather than Jellyfin's", () => {
|
||||
for (const scope of FAVORITE_SCOPES) {
|
||||
expect(FAVORITE_SCOPE_LABELS[scope]).toBeTruthy();
|
||||
}
|
||||
// "tv" is the backend's scope name; users see "Shows".
|
||||
expect(FAVORITE_SCOPE_LABELS.tv).toBe("Shows");
|
||||
});
|
||||
|
||||
it("gives each tab its own empty state, telling the user what to do next", () => {
|
||||
for (const scope of FAVORITE_SCOPES) {
|
||||
expect(emptyStateMessage(scope)).toContain("heart");
|
||||
}
|
||||
expect(emptyStateMessage("movies")).toContain("movies");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Favourites page presentation helpers — which scopes are offered as tabs, what
|
||||
// they are called, and how a tab is addressed in the URL.
|
||||
//
|
||||
// The *labels and tab order* are presentation and live here. What each scope
|
||||
// MEANS in Jellyfin item types is domain vocabulary and lives in Rust
|
||||
// (`SearchScope::item_types`); this file must never enumerate item types.
|
||||
//
|
||||
// TRACES: UR-067 | DR-117
|
||||
|
||||
import type { SearchScope } from "$lib/api/bindings";
|
||||
|
||||
/**
|
||||
* Scopes offered as tabs, in display order. A subset of `SearchScope` chosen
|
||||
* for presentation — the backend accepts more than a page needs to show.
|
||||
*/
|
||||
export const FAVORITE_SCOPES = ["all", "movies", "tv", "music"] as const;
|
||||
|
||||
export type FavoritesScope = (typeof FAVORITE_SCOPES)[number];
|
||||
|
||||
export const FAVORITE_SCOPE_LABELS: Record<FavoritesScope, string> = {
|
||||
all: "All",
|
||||
movies: "Movies",
|
||||
tv: "Shows",
|
||||
music: "Music",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the `?scope=` param to a tab, defaulting to All for anything
|
||||
* missing or unrecognised (a hand-edited or stale URL must not blank the page).
|
||||
*/
|
||||
export function resolveFavoritesScope(raw: string | null | undefined): FavoritesScope {
|
||||
if (!raw) return "all";
|
||||
return (FAVORITE_SCOPES as readonly string[]).includes(raw) ? (raw as FavoritesScope) : "all";
|
||||
}
|
||||
|
||||
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
|
||||
export function favoritesRouteUrl(scope: FavoritesScope): string {
|
||||
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;
|
||||
}
|
||||
|
||||
/** Per-tab empty state copy (ux-flows §5C.2). */
|
||||
export function emptyStateMessage(scope: FavoritesScope): string {
|
||||
const what: Record<FavoritesScope, string> = {
|
||||
all: "Nothing favourited yet",
|
||||
movies: "No favourite movies yet",
|
||||
tv: "No favourite shows yet",
|
||||
music: "No favourite music yet",
|
||||
};
|
||||
return `${what[scope]} — tap the heart on anything you like.`;
|
||||
}
|
||||
|
||||
/** Compile-time guard that every tab is a scope the backend accepts. */
|
||||
const _scopesAreSearchScopes: readonly SearchScope[] = FAVORITE_SCOPES;
|
||||
void _scopesAreSearchScopes;
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
showGlobalHeader,
|
||||
routeOwnsLayout,
|
||||
showBottomUi,
|
||||
shellReservesBottomInset,
|
||||
} from "./layoutShell";
|
||||
|
||||
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
||||
@@ -149,3 +150,32 @@ describe("structural invariant: every route that shows bottom UI has a scroller
|
||||
expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Who owns the bottom safe-area inset.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112 | UT-098
|
||||
*
|
||||
* Exactly one element must reserve `--safe-bottom`, or the Android gesture bar
|
||||
* is either ignored (nav swallowed) or double-padded (a dead strip above it).
|
||||
* BottomUi owns it whenever it renders — the padding sits inside its surface
|
||||
* box so the colour extends behind the bar. Routes with no BottomUi (login, the
|
||||
* full-screen player) leave the app shell to reserve it instead.
|
||||
*/
|
||||
describe("shellReservesBottomInset", () => {
|
||||
it("defers to BottomUi on every route that renders one", () => {
|
||||
for (const pathname of ["/", "/search", "/downloads", "/settings", "/library"]) {
|
||||
expect(showBottomUi(authed(pathname))).toBe(true);
|
||||
expect(shellReservesBottomInset(authed(pathname))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reserves the inset itself on routes with no bottom UI", () => {
|
||||
expect(shellReservesBottomInset(authed("/login"))).toBe(true);
|
||||
expect(shellReservesBottomInset(authed("/player/x"))).toBe(true);
|
||||
});
|
||||
|
||||
it("defers on the unauthenticated shell, where the mini player still renders", () => {
|
||||
expect(shellReservesBottomInset({ pathname: "/", isAuthenticated: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,3 +98,19 @@ export function showGlobalHeader({
|
||||
export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
||||
return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app shell itself must reserve the bottom safe-area inset
|
||||
* (`--safe-bottom`, i.e. the Android navigation/gesture bar).
|
||||
*
|
||||
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
|
||||
* because the padding belongs *inside* its surface box so the colour extends
|
||||
* behind the bar rather than leaving a strip of page background. On routes with
|
||||
* no bottom UI at all (login, the full-screen player) nothing else would, so
|
||||
* the shell takes it.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
*/
|
||||
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
|
||||
return !showBottomUi(input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveLibraryView,
|
||||
libraryViewUrl,
|
||||
LIBRARY_VIEWS,
|
||||
DEFAULT_LIBRARY_VIEW,
|
||||
} from "./libraryView";
|
||||
|
||||
describe("resolveLibraryView", () => {
|
||||
it("resolves each known view", () => {
|
||||
for (const view of LIBRARY_VIEWS) {
|
||||
expect(resolveLibraryView(view)).toBe(view);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to browse when the param is absent", () => {
|
||||
expect(resolveLibraryView(null)).toBe("browse");
|
||||
expect(resolveLibraryView(undefined)).toBe("browse");
|
||||
});
|
||||
|
||||
it("falls back to the default rather than rendering nothing for junk", () => {
|
||||
expect(resolveLibraryView("shows")).toBe(DEFAULT_LIBRARY_VIEW);
|
||||
expect(resolveLibraryView("")).toBe(DEFAULT_LIBRARY_VIEW);
|
||||
});
|
||||
|
||||
it("tolerates case and surrounding whitespace", () => {
|
||||
expect(resolveLibraryView("Genres")).toBe("genres");
|
||||
expect(resolveLibraryView(" all ")).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("libraryViewUrl", () => {
|
||||
it("omits the param for the default view so the landing URL stays clean", () => {
|
||||
expect(libraryViewUrl("/library/tv", "browse")).toBe("/library/tv");
|
||||
});
|
||||
|
||||
it("names the non-default views", () => {
|
||||
expect(libraryViewUrl("/library/tv", "all")).toBe("/library/tv?view=all");
|
||||
expect(libraryViewUrl("/library/movies", "genres")).toBe("/library/movies?view=genres");
|
||||
});
|
||||
|
||||
it("round-trips through resolveLibraryView", () => {
|
||||
for (const view of LIBRARY_VIEWS) {
|
||||
const url = libraryViewUrl("/library/tv", view);
|
||||
const param = new URL(url, "http://x").searchParams.get("view");
|
||||
expect(resolveLibraryView(param)).toBe(view);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Which section of a video library page is showing.
|
||||
//
|
||||
// Browse / All / Genres used to be three routes per library, named
|
||||
// inconsistently across the two libraries (`/library/tv/shows` vs
|
||||
// `/library/movies/all`; `/library/shows/genres` vs `/library/movies/genres`).
|
||||
// They are now one route with tabs, and this is the pure `?view=` ↔ tab
|
||||
// mapping.
|
||||
//
|
||||
// TRACES: UR-063 | DR-105
|
||||
export type LibraryView = "browse" | "all" | "genres";
|
||||
|
||||
/** Tab order, left to right. `browse` leads because it is the landing view. */
|
||||
export const LIBRARY_VIEWS: readonly LibraryView[] = ["browse", "all", "genres"];
|
||||
|
||||
/** The view a page shows when `?view=` is absent or unrecognised. */
|
||||
export const DEFAULT_LIBRARY_VIEW: LibraryView = "browse";
|
||||
|
||||
/**
|
||||
* Read a `?view=` value. Anything unknown — a typo, a stale bookmark, a
|
||||
* removed tab — lands on the default rather than rendering nothing.
|
||||
*/
|
||||
export function resolveLibraryView(value: string | null | undefined): LibraryView {
|
||||
if (value == null) return DEFAULT_LIBRARY_VIEW;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (LIBRARY_VIEWS as readonly string[]).includes(normalized)
|
||||
? (normalized as LibraryView)
|
||||
: DEFAULT_LIBRARY_VIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL for a tab. The default view omits the param, so the landing URL stays
|
||||
* `/library/tv` — the same convention `searchRouteUrl` uses for the `all` scope.
|
||||
*/
|
||||
export function libraryViewUrl(basePath: string, view: LibraryView): string {
|
||||
return view === DEFAULT_LIBRARY_VIEW ? basePath : `${basePath}?view=${view}`;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
ZERO_INSETS,
|
||||
parseNativeInsets,
|
||||
safeAreaCssVars,
|
||||
applySafeAreaInsets,
|
||||
readNativeInsets,
|
||||
initSafeArea,
|
||||
INSETS_CHANGED_EVENT,
|
||||
} from "./safeArea";
|
||||
|
||||
/**
|
||||
* Safe-area (window inset) plumbing for Android.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112 | UT-094, UT-095, UT-096, UT-097
|
||||
*
|
||||
* Regression guard for "the bottom nav is off the bottom of the screen on some
|
||||
* devices (Motorola) but not others (Fairphone)".
|
||||
*
|
||||
* Two independent defects produced it:
|
||||
*
|
||||
* 1. `src/app.html` shipped `<meta name="viewport" content="width=device-width,
|
||||
* initial-scale=1">` — no `viewport-fit=cover`. Per the CSS Env spec, every
|
||||
* `env(safe-area-inset-*)` resolves to **0px** unless the viewport opts into
|
||||
* `cover`. So the `env()` padding in app.css and BottomUi.svelte was a
|
||||
* no-op on every device.
|
||||
* 2. Even with `viewport-fit=cover`, Android WebView only maps the **display
|
||||
* cutout** into `env(safe-area-inset-*)` — never the status bar or the
|
||||
* navigation/gesture bar. MainActivity calls `enableEdgeToEdge()` and the
|
||||
* app targets SDK 36 (edge-to-edge is mandatory from SDK 35 and the opt-out
|
||||
* is ignored from SDK 36), so the WebView always spans the full window
|
||||
* including the system bars. CSS alone can never learn about them.
|
||||
*
|
||||
* The device split was only in how much the bars intrude: a thin translucent
|
||||
* gesture pill overlaps harmlessly, a tall opaque 3-button bar swallows the nav
|
||||
* outright. Both devices were equally unpadded.
|
||||
*
|
||||
* The fix pushes real `WindowInsets` from Kotlin into CSS custom properties.
|
||||
* These tests pin the frontend half of that contract.
|
||||
*/
|
||||
describe("parseNativeInsets", () => {
|
||||
it("parses the JSON payload the native bridge returns", () => {
|
||||
expect(parseNativeInsets('{"top":24,"right":0,"bottom":48,"left":0}')).toEqual({
|
||||
top: 24,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
left: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an already-parsed object", () => {
|
||||
expect(parseNativeInsets({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({
|
||||
top: 1,
|
||||
right: 2,
|
||||
bottom: 3,
|
||||
left: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing, non-finite and negative edges as zero rather than emitting NaN", () => {
|
||||
// A NaN would serialise to "NaNpx" and silently kill the whole padding
|
||||
// declaration, which is exactly the failure mode being guarded against.
|
||||
expect(parseNativeInsets('{"top":-5,"bottom":"48"}')).toEqual({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
left: 0,
|
||||
});
|
||||
expect(parseNativeInsets({ top: Number.NaN, right: Infinity })).toEqual(ZERO_INSETS);
|
||||
});
|
||||
|
||||
it("returns null for input that is not an inset payload at all", () => {
|
||||
expect(parseNativeInsets("not json")).toBeNull();
|
||||
expect(parseNativeInsets(null)).toBeNull();
|
||||
expect(parseNativeInsets(undefined)).toBeNull();
|
||||
expect(parseNativeInsets(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeAreaCssVars / applySafeAreaInsets", () => {
|
||||
it("emits px-suffixed custom properties for all four edges", () => {
|
||||
expect(safeAreaCssVars({ top: 24, right: 0, bottom: 48, left: 12 })).toEqual({
|
||||
"--jt-inset-top": "24px",
|
||||
"--jt-inset-right": "0px",
|
||||
"--jt-inset-bottom": "48px",
|
||||
"--jt-inset-left": "12px",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the custom properties onto the target element", () => {
|
||||
const el = document.createElement("div");
|
||||
applySafeAreaInsets(el, { top: 24, right: 1, bottom: 48, left: 2 });
|
||||
|
||||
expect(el.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-right")).toBe("1px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-left")).toBe("2px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readNativeInsets", () => {
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidInsets;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when the bridge is absent (desktop, iOS, dev server)", () => {
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
});
|
||||
|
||||
it("reads and parses the bridge payload", () => {
|
||||
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
|
||||
expect(readNativeInsets()).toEqual({ top: 24, right: 0, bottom: 48, left: 0 });
|
||||
});
|
||||
|
||||
it("returns null when the bridge object is a stale WebView proxy", () => {
|
||||
// Same failure mode as the background-audio bridge: the injected object
|
||||
// stays truthy across a page load while its methods vanish. Must not throw
|
||||
// out of layout init.
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
window.AndroidInsets = {} as unknown as { get(): string };
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
|
||||
window.AndroidInsets = {
|
||||
get: () => {
|
||||
throw new TypeError("get is not a function");
|
||||
},
|
||||
};
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSafeArea", () => {
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidInsets;
|
||||
document.documentElement.removeAttribute("style");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
});
|
||||
|
||||
it("primes the document element from the bridge on start", () => {
|
||||
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
|
||||
|
||||
stop = initSafeArea();
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
|
||||
});
|
||||
|
||||
it("re-applies insets when native reports a change (rotation, nav-mode switch)", () => {
|
||||
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
|
||||
window.AndroidInsets = { get: () => payload };
|
||||
|
||||
stop = initSafeArea();
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
|
||||
// Rotated to landscape: the cutout moves to the left edge, the gesture bar
|
||||
// shrinks. Native re-pushes and fires the change event.
|
||||
payload = '{"top":0,"right":0,"bottom":24,"left":44}';
|
||||
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("0px");
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-left")).toBe("44px");
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("24px");
|
||||
});
|
||||
|
||||
it("leaves the custom properties unset with no bridge, so env() keeps the field", () => {
|
||||
// On iOS/desktop the `env(safe-area-inset-*)` half of the max() must win;
|
||||
// writing an explicit 0px here would clobber it.
|
||||
stop = initSafeArea();
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("");
|
||||
});
|
||||
|
||||
it("stops listening once torn down", () => {
|
||||
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
|
||||
window.AndroidInsets = { get: () => payload };
|
||||
|
||||
const teardown = initSafeArea();
|
||||
teardown();
|
||||
|
||||
payload = '{"top":99,"right":99,"bottom":99,"left":99}';
|
||||
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Static guards. The two root-cause defects were both single lines of markup /
|
||||
* CSS that no runtime test could reach, so pin them at the source level.
|
||||
*/
|
||||
describe("safe-area wiring in source", () => {
|
||||
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8");
|
||||
|
||||
it("app.html opts the viewport into viewport-fit=cover", () => {
|
||||
const viewport = read("src/app.html").match(/<meta\s+name="viewport"[^>]*>/i)?.[0];
|
||||
|
||||
expect(viewport, "no viewport meta tag found in src/app.html").toBeTruthy();
|
||||
expect(viewport).toMatch(/viewport-fit\s*=\s*cover/);
|
||||
});
|
||||
|
||||
it("app.css derives --safe-* from both env() and the native --jt-inset-* vars", () => {
|
||||
const css = read("src/app.css");
|
||||
|
||||
for (const edge of ["top", "right", "bottom", "left"]) {
|
||||
expect(css).toMatch(
|
||||
new RegExp(
|
||||
`--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("no component pads directly from env() — everything goes through --safe-*", () => {
|
||||
// A bare env() is 0 in Android WebView for the system bars, which is the
|
||||
// bug. app.css is the one legal place it may appear (inside the max()).
|
||||
const offenders = [
|
||||
"src/lib/components/BottomUi.svelte",
|
||||
"src/routes/+layout.svelte",
|
||||
"src/lib/components/player/VideoPlayer.svelte",
|
||||
"src/lib/components/player/AudioPlayer.svelte",
|
||||
].filter((f) => read(f).includes("env(safe-area-inset"));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the bottom UI reserves the bottom inset so the nav clears the gesture bar", () => {
|
||||
expect(read("src/lib/components/BottomUi.svelte")).toMatch(/pb-\[var\(--safe-bottom\)\]/);
|
||||
});
|
||||
|
||||
it("only the app shell measures itself against the viewport", () => {
|
||||
// The shell is `h-screen` AND inset-padded, so its content box is
|
||||
// `100vh - safe-top`. Any nested `h-screen`/`min-h-screen` is therefore
|
||||
// taller than the space it was given and overflows by exactly the inset —
|
||||
// the library column's `h-screen` clipped its own BottomUi that way. Nested
|
||||
// full-height boxes must use `h-full`/`min-h-full` and inherit the shell's
|
||||
// already-inset height.
|
||||
const svelteFilesIn = (dir: string): string[] =>
|
||||
readdirSync(resolve(process.cwd(), dir), { recursive: true, encoding: "utf8" })
|
||||
.filter((f) => f.endsWith(".svelte"))
|
||||
.map((f) => `${dir}/${f}`);
|
||||
|
||||
const offenders = [...svelteFilesIn("src/routes"), ...svelteFilesIn("src/lib/components")]
|
||||
.filter((f) => f !== "src/routes/+layout.svelte")
|
||||
.filter((f) => /class=[^>]*\bh-screen\b|class=[^>]*\bmin-h-screen\b/.test(read(f)));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Safe-area (window inset) plumbing.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* `MainActivity` calls `enableEdgeToEdge()`, and the app targets SDK 36 — from
|
||||
* SDK 35 edge-to-edge is mandatory and from SDK 36 the opt-out is ignored — so
|
||||
* the Tauri WebView always spans the **entire window**, underneath the status
|
||||
* bar, the navigation/gesture bar and the display cutout.
|
||||
*
|
||||
* CSS cannot discover that on its own:
|
||||
*
|
||||
* - `env(safe-area-inset-*)` resolves to `0px` unless the viewport declares
|
||||
* `viewport-fit=cover` (see `src/app.html`), and
|
||||
* - even then, Android WebView only maps the **display cutout** into
|
||||
* `env(safe-area-inset-*)`. The status bar and the navigation bar are never
|
||||
* reported. Unlike iOS Safari, there is no CSS-visible system-bar inset.
|
||||
*
|
||||
* So native reads the real `WindowInsets` (`systemBars() | displayCutout()`)
|
||||
* and pushes them in as CSS custom properties; `src/app.css` folds them
|
||||
* together with `env()` via `max()` so iOS/desktop keep working unchanged:
|
||||
*
|
||||
* ```css
|
||||
* --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--jt-inset-bottom, 0px));
|
||||
* ```
|
||||
*
|
||||
* Two delivery paths, because either alone is insufficient:
|
||||
*
|
||||
* - **push** — `WindowInsetsBridge` evaluates JS into the WebView on every
|
||||
* inset change (rotation, nav-mode switch, PiP enter/exit). Needed because
|
||||
* insets change after load.
|
||||
* - **pull** — `initSafeArea()` reads `window.AndroidInsets.get()` at startup.
|
||||
* Needed because the first inset pass usually lands *before* the SvelteKit
|
||||
* document exists, and a page load wipes any inline style native had set.
|
||||
*/
|
||||
|
||||
/** Window insets in CSS pixels, one per edge. */
|
||||
export interface SafeAreaInsets {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
/** No insets — the desktop/dev default. */
|
||||
export const ZERO_INSETS: SafeAreaInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/** DOM event native fires after pushing a new set of insets. */
|
||||
export const INSETS_CHANGED_EVENT = "jellytau-insets-changed";
|
||||
|
||||
const EDGES = ["top", "right", "bottom", "left"] as const;
|
||||
|
||||
/** The native @JavascriptInterface installed by MainActivity (Android only). */
|
||||
interface AndroidInsetsBridge {
|
||||
/** JSON `{top,right,bottom,left}` in CSS pixels. */
|
||||
get(): string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidInsets?: AndroidInsetsBridge;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce one edge to a non-negative finite number.
|
||||
*
|
||||
* Anything else becomes 0 rather than propagating: a `NaN` would serialise to
|
||||
* `"NaNpx"`, which invalidates the whole declaration and silently restores the
|
||||
* original bug.
|
||||
*/
|
||||
function edge(value: unknown): number {
|
||||
const n = typeof value === "string" ? Number(value) : value;
|
||||
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a native inset payload (JSON string or already-decoded object).
|
||||
* Returns `null` when the input is not an inset payload at all, so callers can
|
||||
* distinguish "no insets reported" from "insets are all zero".
|
||||
*/
|
||||
export function parseNativeInsets(raw: unknown): SafeAreaInsets | null {
|
||||
let value = raw;
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (!EDGES.some((e) => e in record)) return null;
|
||||
|
||||
return {
|
||||
top: edge(record.top),
|
||||
right: edge(record.right),
|
||||
bottom: edge(record.bottom),
|
||||
left: edge(record.left),
|
||||
};
|
||||
}
|
||||
|
||||
/** The CSS custom properties for a set of insets. */
|
||||
export function safeAreaCssVars(insets: SafeAreaInsets): Record<string, string> {
|
||||
return Object.fromEntries(EDGES.map((e) => [`--jt-inset-${e}`, `${insets[e]}px`]));
|
||||
}
|
||||
|
||||
/** Write the inset custom properties onto an element (normally `<html>`). */
|
||||
export function applySafeAreaInsets(target: HTMLElement, insets: SafeAreaInsets): void {
|
||||
for (const [name, value] of Object.entries(safeAreaCssVars(insets))) {
|
||||
target.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current insets from the native bridge, or `null` when there is none.
|
||||
*
|
||||
* Never throws. WebView can hand JS a *stale proxy* after a page load — the
|
||||
* injected object stays truthy while its methods vanish (the exact failure that
|
||||
* broke the background-audio toggle, see `MainActivity.configureWebViewForMedia`).
|
||||
* Layout init must survive that.
|
||||
*/
|
||||
export function readNativeInsets(): SafeAreaInsets | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const bridge = window.AndroidInsets;
|
||||
if (!bridge || typeof bridge.get !== "function") return null;
|
||||
|
||||
try {
|
||||
return parseNativeInsets(bridge.get());
|
||||
} catch (err) {
|
||||
console.warn("[SafeArea] AndroidInsets bridge unusable:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime the safe-area custom properties and keep them current.
|
||||
*
|
||||
* Call once, early in the root layout's `onMount` (synchronously — before any
|
||||
* `await`, so the very first paint is already inset-correct). Returns a
|
||||
* teardown that unsubscribes.
|
||||
*
|
||||
* With no native bridge this is a near no-op: it deliberately does NOT write
|
||||
* `0px`, so the `env(safe-area-inset-*)` half of the `max()` still wins on iOS
|
||||
* and desktop.
|
||||
*/
|
||||
export function initSafeArea(target?: HTMLElement): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
|
||||
const el = target ?? document.documentElement;
|
||||
|
||||
const sync = () => {
|
||||
const insets = readNativeInsets();
|
||||
if (insets) applySafeAreaInsets(el, insets);
|
||||
};
|
||||
|
||||
sync();
|
||||
window.addEventListener(INSETS_CHANGED_EVENT, sync);
|
||||
return () => window.removeEventListener(INSETS_CHANGED_EVENT, sync);
|
||||
}
|
||||
@@ -42,7 +42,9 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
||||
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
|
||||
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
|
||||
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
|
||||
// `/library/shows/genres` is the TV genre route despite the differing segment.
|
||||
// `/library/shows/*` is a legacy TV route that now redirects into
|
||||
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
|
||||
// before the redirect lands still scopes to TV.
|
||||
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
|
||||
|
||||
return "all";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import { page } from "$app/stores";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import "../app.css";
|
||||
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { connectivity, isConnected } from "$lib/stores/connectivity";
|
||||
@@ -10,7 +11,8 @@
|
||||
import { initWebviewAudio, cleanupWebviewAudio } from "$lib/services/webviewAudio";
|
||||
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
|
||||
import { clearFavorite } from "$lib/stores/favorites";
|
||||
import { onReconnected as onCatalogReconnected, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sessions } from "$lib/stores/sessions";
|
||||
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
||||
@@ -24,14 +26,20 @@
|
||||
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
|
||||
showGlobalHeader as computeShowGlobalHeader,
|
||||
routeOwnsLayout as computeRouteOwnsLayout,
|
||||
shellReservesBottomInset,
|
||||
} from "$lib/utils/layoutShell";
|
||||
import { registerNavigationTracking } from "$lib/utils/navigation";
|
||||
import { startNetworkReporting } from "$lib/services/networkType";
|
||||
import { initSafeArea } from "$lib/utils/safeArea";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
/** Teardown for the network-transport reporter (WiFi-only gate). */
|
||||
let stopNetworkReporting: (() => void) | null = null;
|
||||
let stopFavoritesListener: UnlistenFn | null = null;
|
||||
|
||||
/** Teardown for the native window-inset subscription (safe areas). */
|
||||
let stopSafeArea: (() => void) | null = null;
|
||||
|
||||
// Track in-app navigation depth so the header "back" affordance knows when a
|
||||
// real in-app Back exists (vs. a stale WebView stack after a background /
|
||||
@@ -67,6 +75,14 @@
|
||||
// scroller, with the root's in-flow BottomUi as a flex sibling below it.
|
||||
const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname }));
|
||||
|
||||
// Bottom safe-area inset (Android navigation/gesture bar): BottomUi reserves
|
||||
// it wherever one renders, so the shell only takes it on routes that have no
|
||||
// bottom UI at all (login, the full-screen player). Exactly one owner, or the
|
||||
// bar is either ignored or double-padded. (UR-066)
|
||||
const shellPadsBottom = $derived(
|
||||
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated })
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
// Detect platform first (synchronously, before any await) so the global
|
||||
// mini player's Android visibility gate is correct from the first render.
|
||||
@@ -80,6 +96,13 @@
|
||||
console.error("Platform detection failed:", err);
|
||||
}
|
||||
|
||||
// Prime the safe-area custom properties from the native WindowInsets bridge
|
||||
// BEFORE the first await, so the very first paint already clears the status
|
||||
// bar and the navigation/gesture bar. Native also pushes updates directly,
|
||||
// but a page load wipes the inline style it set, so this pull is required.
|
||||
// No-op without the Android bridge. (UR-066)
|
||||
stopSafeArea = initSafeArea();
|
||||
|
||||
// Initialize auth state (restore session from secure storage)
|
||||
await auth.initialize();
|
||||
isInitialized.set(true);
|
||||
@@ -95,6 +118,18 @@
|
||||
// Initialize download event listener
|
||||
await initDownloadEvents();
|
||||
|
||||
// Favourite state can change behind the UI: another Jellyfin client marks
|
||||
// something, or the Rust drain pushes toggles queued while offline. Drop
|
||||
// the session overrides for those ids so the next render reads the freshly
|
||||
// cached server value rather than a stale local guess.
|
||||
// TRACES: UR-069 | DR-120
|
||||
stopFavoritesListener = await listen<{ itemIds: string[] }>(
|
||||
"favorites-changed",
|
||||
(event) => {
|
||||
for (const id of event.payload?.itemIds ?? []) clearFavorite(id);
|
||||
}
|
||||
);
|
||||
|
||||
// Report the network transport to the backend and keep it current, so the
|
||||
// WiFi-only download gate has real data to act on (UR-053). No-op on
|
||||
// desktop, where the backend defaults to unmetered.
|
||||
@@ -114,10 +149,10 @@
|
||||
// Start sync service for offline mutation queue
|
||||
syncService.start();
|
||||
|
||||
// Kick off a best-effort full-catalog pre-sync so the whole server catalog
|
||||
// is browsable (greyed out) offline, and load the last-sync hint for the
|
||||
// offline banner. Non-blocking — no-ops when not connected.
|
||||
void syncCatalog();
|
||||
// Load the last-sync hint for the offline banner. The catalog *index* is no
|
||||
// longer kicked off from here: the Rust background indexer (DR-109) owns
|
||||
// when to re-index, so a long session no longer searches a stale catalog and
|
||||
// a restart no longer forces a full crawl regardless of freshness.
|
||||
void refreshSyncStatus();
|
||||
|
||||
// Initialize playback mode and session monitoring
|
||||
@@ -127,6 +162,9 @@
|
||||
|
||||
onDestroy(() => {
|
||||
stopNetworkReporting?.();
|
||||
stopFavoritesListener?.();
|
||||
stopFavoritesListener = null;
|
||||
stopSafeArea?.();
|
||||
cleanupPlayerEvents();
|
||||
cleanupWebviewAudio();
|
||||
cleanupDownloadEvents();
|
||||
@@ -177,7 +215,19 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col">
|
||||
<!--
|
||||
The app shell reserves the top/side safe-area insets (status bar, display
|
||||
cutout) so no route has to. The bottom inset belongs to BottomUi wherever one
|
||||
renders — see shellReservesBottomInset. `h-screen` is border-box, so the
|
||||
padding is taken out of the 100vh rather than added to it.
|
||||
|
||||
TRACES: UR-066 | DR-112
|
||||
-->
|
||||
<div
|
||||
class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col
|
||||
pt-[var(--safe-top)] pl-[var(--safe-left)] pr-[var(--safe-right)]"
|
||||
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
|
||||
>
|
||||
{#if isInitialized}
|
||||
<!-- Offline indicator banner -->
|
||||
{#if $isAuthenticated && !$isConnected}
|
||||
@@ -249,7 +299,7 @@
|
||||
onClose={() => showSleepTimerModal.set(false)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-screen">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+40
-2
@@ -133,16 +133,21 @@
|
||||
const nextUpItems = $derived($home.nextUpItems);
|
||||
const latestItems = $derived($home.latestItems);
|
||||
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
|
||||
// Favourite rows. Each is hidden when empty, so a fresh install shows none.
|
||||
// TRACES: UR-067 | DR-118
|
||||
const favoriteMovies = $derived($home.favoriteMovies);
|
||||
const favoriteShows = $derived($home.favoriteShows);
|
||||
const favoriteMusic = $derived($home.favoriteMusic);
|
||||
const resumeMovies = $derived($home.resumeMovies);
|
||||
const isLoading = $derived($home.isLoading);
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="h-screen flex justify-center items-center">
|
||||
<div class="h-full flex justify-center items-center">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div class="space-y-8">
|
||||
|
||||
<!-- Hero Banner -->
|
||||
@@ -218,6 +223,39 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Favourites. Hidden entirely when a category has nothing in it —
|
||||
empty rows on a fresh install read as broken. ux-flows §5C.2.
|
||||
TRACES: UR-067 | DR-118 -->
|
||||
{#if favoriteMovies.length > 0}
|
||||
<Carousel
|
||||
title="Favourite Movies"
|
||||
items={favoriteMovies}
|
||||
onItemClick={handleItemClick}
|
||||
onItemLongPress={handleItemLongPress}
|
||||
showAll={() => goto("/library/favorites?scope=movies")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if favoriteShows.length > 0}
|
||||
<Carousel
|
||||
title="Favourite Shows"
|
||||
items={favoriteShows}
|
||||
onItemClick={handleItemClick}
|
||||
onItemLongPress={handleItemLongPress}
|
||||
showAll={() => goto("/library/favorites?scope=tv")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if favoriteMusic.length > 0}
|
||||
<Carousel
|
||||
title="Favourite Music"
|
||||
items={favoriteMusic}
|
||||
onItemClick={handleItemClick}
|
||||
onItemLongPress={handleItemLongPress}
|
||||
showAll={() => goto("/library/favorites?scope=music")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Access -->
|
||||
<div class="pt-4 px-4">
|
||||
<button
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
</script>
|
||||
|
||||
{#if $isAuthLoading}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="min-h-full flex items-center justify-center">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if $isAuthenticated}
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Header (shared across all authenticated chrome; library supplies search) -->
|
||||
<AppHeader search={librarySearch} />
|
||||
|
||||
|
||||
@@ -201,6 +201,20 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Your Libraries</h1>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Favourites cut across libraries, so they live beside the library
|
||||
list rather than inside one. ux-flows §5C.2.
|
||||
TRACES: UR-067 | DR-117 -->
|
||||
<button
|
||||
onclick={() => goto('/library/favorites')}
|
||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||
title="Favourites"
|
||||
aria-label="Favourites"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => goto('/settings')}
|
||||
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
||||
@@ -211,6 +225,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $isLibraryLoading}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-035, UR-038, UR-048 | DR-043, DR-062 -->
|
||||
<!-- TRACES: UR-035, UR-038, UR-048, UR-062 | DR-043, DR-062, DR-102, DR-103 -->
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
@@ -17,7 +17,10 @@
|
||||
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
|
||||
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
||||
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
|
||||
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
|
||||
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
import CastSection from "$lib/components/library/CastSection.svelte";
|
||||
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
|
||||
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
|
||||
@@ -28,17 +31,27 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
|
||||
|
||||
interface SeasonData {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
}
|
||||
import {
|
||||
groupEpisodesBySeason,
|
||||
seasonAnchorId,
|
||||
seasonRedirectTarget,
|
||||
episodeFocusHref,
|
||||
seriesPlayHref,
|
||||
seriesPlayLabel,
|
||||
initialExpandedSeasons,
|
||||
type SeasonData,
|
||||
} from "$lib/components/library/seriesNavigation";
|
||||
|
||||
let item = $state<MediaItem | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let seasonData = $state<SeasonData[]>([]);
|
||||
let directFetchedEpisode = $state<MediaItem | null>(null);
|
||||
// The episode the viewer is up to. Resolved by Rust (DR-101), not here.
|
||||
let currentEpisode = $state<MediaItem | null>(null);
|
||||
// Season ids whose episode list is open. A reading position, not a saved
|
||||
// preference, so it resets with each load (DR-107).
|
||||
let expandedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
// Track if we've done an initial load and previous server state
|
||||
let hasLoadedOnce = false;
|
||||
@@ -81,10 +94,25 @@
|
||||
error = null;
|
||||
seasonData = [];
|
||||
directFetchedEpisode = null;
|
||||
currentEpisode = null;
|
||||
expandedSeasons = new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
item = await library.loadItem(itemId);
|
||||
|
||||
// A season is not a destination — send it to its series, anchored at that
|
||||
// season, so the episodes of every season stay one continuous list.
|
||||
// TRACES: UR-062 | DR-103
|
||||
if (item?.kind === "season") {
|
||||
const target = seasonRedirectTarget(item);
|
||||
if (target) {
|
||||
await goto(target, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
// No seriesId (stale cache / deep link) — fall through to the generic
|
||||
// rendering below rather than stranding the user.
|
||||
}
|
||||
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
|
||||
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||
if (item?.people) {
|
||||
@@ -129,64 +157,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
// For Series, load seasons and their episodes
|
||||
// For Series, load every episode across all seasons plus the episode the
|
||||
// viewer is up to. Both come from Rust: the season fan-out (and the
|
||||
// flat-series fallback for shows whose children are episodes rather than
|
||||
// season folders) is Jellyfin's shape, and "which episode is current" is
|
||||
// domain policy — neither belongs in the presentation layer.
|
||||
// TRACES: UR-062 | DR-101, DR-102
|
||||
if (item?.kind === "series") {
|
||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||
const repo = auth.getRepository();
|
||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||
|
||||
// Load episodes for each season in parallel
|
||||
const seasonDataPromises = seasons.map(async (season) => {
|
||||
const result = await repo.getItems(season.id, { limit: 100 });
|
||||
const episodes = result.items
|
||||
.filter((i) => i.kind === "episode")
|
||||
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
|
||||
return { season, episodes };
|
||||
});
|
||||
const [episodes, current] = await Promise.all([
|
||||
repo.getSeriesEpisodes(itemId),
|
||||
// Best-effort: a series still renders if the anchor cannot be resolved.
|
||||
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
||||
console.warn("Could not resolve the current episode:", e);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
seasonData = await Promise.all(seasonDataPromises);
|
||||
// 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)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
seasonData = groupEpisodesBySeason(seasons, episodes);
|
||||
currentEpisode = current;
|
||||
// Open only the season the viewer is in (DR-107).
|
||||
expandedSeasons = initialExpandedSeasons(
|
||||
seasonData,
|
||||
current?.id,
|
||||
$page.url.searchParams.get("episode")
|
||||
);
|
||||
|
||||
// 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");
|
||||
if (episodeIdParam) {
|
||||
const allEps = seasonData.flatMap((s) => s.episodes);
|
||||
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
|
||||
if (!foundInSeasons) {
|
||||
try {
|
||||
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
||||
} catch {
|
||||
console.warn("Could not fetch focused episode directly:", episodeIdParam);
|
||||
}
|
||||
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
|
||||
try {
|
||||
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
||||
} catch {
|
||||
console.warn("Could not fetch focused episode directly:", episodeIdParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,14 +230,21 @@
|
||||
return;
|
||||
}
|
||||
switch (clickedItem.kind) {
|
||||
case "series":
|
||||
// A season link lands on its series, anchored at that season — seasons
|
||||
// have no page of their own (DR-103).
|
||||
case "season":
|
||||
goto(seasonRedirectTarget(clickedItem) ?? `/library/${clickedItem.id}`);
|
||||
break;
|
||||
// An episode always opens in the context of its series (ux-flows §5B.1).
|
||||
case "episode":
|
||||
goto(episodeFocusHref(clickedItem));
|
||||
break;
|
||||
case "series":
|
||||
case "album":
|
||||
case "artist":
|
||||
case "folder":
|
||||
case "playlist":
|
||||
case "channel":
|
||||
case "episode":
|
||||
case "movie":
|
||||
goto(`/library/${clickedItem.id}`);
|
||||
break;
|
||||
@@ -244,15 +257,29 @@
|
||||
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
|
||||
// This fixes Android playback issues where navigation-based approach was hanging
|
||||
|
||||
function toggleSeason(seasonId: string) {
|
||||
// Reassign rather than mutate — a Set mutation is invisible to $state.
|
||||
const next = new Set(expandedSeasons);
|
||||
if (!next.delete(seasonId)) next.add(seasonId);
|
||||
expandedSeasons = next;
|
||||
}
|
||||
|
||||
function handleEpisodeClick(episode: MediaItem) {
|
||||
// Play the episode with the series queued for next episode
|
||||
goto(`/player/${episode.id}`);
|
||||
// Swap focus to the episode in place; playback starts from the focus view's
|
||||
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
|
||||
goto(episodeFocusHref(episode));
|
||||
}
|
||||
|
||||
async function handlePlayAll() {
|
||||
// For single items (Episode, Movie), play the item directly
|
||||
if (item?.kind === "episode" || item?.kind === "movie") {
|
||||
goto(`/player/${itemId}`);
|
||||
} else if (item?.kind === "series" && itemId) {
|
||||
// Open the episode the viewer is up to, where an explicit Play/Resume
|
||||
// commits. Play on a container navigates; Play on a leaf plays.
|
||||
// TRACES: UR-062 | DR-102
|
||||
const target = seriesPlayHref(itemId, currentEpisode);
|
||||
if (target) goto(target);
|
||||
} else if (item?.kind === "album" && $libraryItems.length > 0) {
|
||||
// For albums, use the backend command (backend fetches and queues all tracks)
|
||||
try {
|
||||
@@ -293,6 +320,11 @@
|
||||
console.error("Failed to shuffle play album:", e);
|
||||
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
} else if (item?.kind === "series" && allEpisodes.length > 0) {
|
||||
// Shuffle a *series* means a random episode, not a random season — the
|
||||
// player has nothing to do with a season id.
|
||||
const random = allEpisodes[Math.floor(Math.random() * allEpisodes.length)];
|
||||
goto(`/player/${random.id}?restart=true`);
|
||||
} else if ($libraryItems.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
|
||||
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
|
||||
@@ -304,6 +336,10 @@
|
||||
seasonData.flatMap((s) => s.episodes)
|
||||
);
|
||||
|
||||
const playLabel = $derived(item?.kind === "series" ? seriesPlayLabel(currentEpisode) : "Play");
|
||||
// An empty series has nowhere for the hero button to lead.
|
||||
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
|
||||
|
||||
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
|
||||
const focusedEpisode = $derived(
|
||||
focusedEpisodeId
|
||||
@@ -422,9 +458,11 @@
|
||||
{/if}
|
||||
{#if item.parentIndexNumber || item.indexNumber}
|
||||
<p class="text-lg text-gray-400 mt-1">
|
||||
{#if item.seasonId && item.parentIndexNumber}
|
||||
<!-- Links to the season's place in the series list, not to a
|
||||
season page — seasons have none (DR-103). -->
|
||||
{#if item.seriesId && item.parentIndexNumber}
|
||||
<a
|
||||
href={`/library/${item.seasonId}`}
|
||||
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
|
||||
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>Season {item.parentIndexNumber}</a>
|
||||
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
||||
@@ -466,15 +504,17 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={handlePlayAll}
|
||||
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
{#if canPlay}
|
||||
<button
|
||||
onclick={handlePlayAll}
|
||||
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{playLabel}
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.kind !== "episode" && item.kind !== "movie"}
|
||||
<button
|
||||
onclick={handleShufflePlay}
|
||||
@@ -498,6 +538,12 @@
|
||||
seriesName={item.name}
|
||||
episodeCount={allEpisodes.length || undefined}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
scope="series"
|
||||
onCleared={loadItem}
|
||||
/>
|
||||
{:else if item.kind === "movie"}
|
||||
<VideoDownloadButton
|
||||
itemId={item.id}
|
||||
@@ -513,6 +559,14 @@
|
||||
size="lg"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
|
||||
<FavoriteButton
|
||||
itemId={item.id}
|
||||
isFavorite={resolveIsFavorite(item, $favoriteOverrides)}
|
||||
size="lg"
|
||||
className="self-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
@@ -627,7 +681,11 @@
|
||||
{season}
|
||||
{episodes}
|
||||
focusedEpisodeId={focusedEpisodeId ?? undefined}
|
||||
currentEpisodeId={currentEpisode?.id}
|
||||
expanded={expandedSeasons.has(season.id)}
|
||||
onToggle={() => toggleSeason(season.id)}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
onHistoryCleared={loadItem}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
Favourites — everything the viewer has hearted, across every library.
|
||||
|
||||
Scope tabs are `?scope=`, so a tab is linkable and survives a back press (the
|
||||
same convention as the video library `?view=` tabs). Each tab sends an opaque
|
||||
`SearchScope`; what it *means* in Jellyfin item types is expanded in Rust
|
||||
(`SearchScope::item_types`), never here — see docs/specs/scoped-search-boundary.md.
|
||||
|
||||
ux-flows §5C.2. TRACES: UR-067 | DR-117
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
import { favoriteOverrides, retainFavorites } from "$lib/stores/favorites";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import {
|
||||
FAVORITE_SCOPES,
|
||||
FAVORITE_SCOPE_LABELS,
|
||||
resolveFavoritesScope,
|
||||
favoritesRouteUrl,
|
||||
emptyStateMessage,
|
||||
} from "$lib/utils/favoritesView";
|
||||
|
||||
const scope = $derived(resolveFavoritesScope($page.url.searchParams.get("scope")));
|
||||
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let unlistenFavorites: UnlistenFn | null = null;
|
||||
|
||||
// Un-hearting here must remove the card immediately rather than wait for a
|
||||
// refetch; a newly hearted item stays put. TRACES: UR-067 | DR-117 | UT-106
|
||||
const visibleItems = $derived(retainFavorites(items, $favoriteOverrides));
|
||||
|
||||
async function load(currentScope = scope) {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getFavorites(currentScope, { limit: 500 });
|
||||
items = result.items;
|
||||
} catch (error) {
|
||||
console.error("Failed to load favorites:", error);
|
||||
loadError = "Could not load your favourites.";
|
||||
items = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
markLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload when the tab changes.
|
||||
let loadedScope = "";
|
||||
$effect(() => {
|
||||
if (scope === loadedScope) return;
|
||||
loadedScope = scope;
|
||||
load(scope);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// The backend reports ids whose favourite state changed behind our back —
|
||||
// a favourite marked in another client, or pending toggles pushed on
|
||||
// reconnect. Refetch rather than patch: the scope decides what belongs.
|
||||
// TRACES: UR-069 | DR-120
|
||||
unlistenFavorites = await listen("favorites-changed", () => {
|
||||
load(scope);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlistenFavorites?.();
|
||||
unlistenFavorites = null;
|
||||
});
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(() => load(scope));
|
||||
|
||||
function selectScope(next: (typeof FAVORITE_SCOPES)[number]) {
|
||||
if (next === scope) return;
|
||||
// replaceState: switching tabs is not a back-press-worthy navigation step.
|
||||
goto(favoritesRouteUrl(next), { replaceState: true, noScroll: true });
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem | Library) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 px-4 pt-4">
|
||||
<BackButton onClick={() => navigateBack("/library")} />
|
||||
<h1 class="text-2xl font-bold text-white">Favourites</h1>
|
||||
</div>
|
||||
|
||||
<nav class="flex items-center gap-1 px-4" aria-label="Favourite categories">
|
||||
{#each FAVORITE_SCOPES as tab (tab)}
|
||||
<button
|
||||
onclick={() => selectScope(tab)}
|
||||
aria-current={tab === scope ? "page" : undefined}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{tab === scope
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
||||
>
|
||||
{FAVORITE_SCOPE_LABELS[tab]}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="px-4 pb-8">
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each Array(12) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-gray-400 py-12 text-center">{loadError}</p>
|
||||
{:else if visibleItems.length === 0}
|
||||
<div class="py-16 text-center space-y-2">
|
||||
<p class="text-gray-300">{emptyStateMessage(scope)}</p>
|
||||
{#if !$isServerReachable}
|
||||
<p class="text-sm text-gray-500">
|
||||
Offline — showing favourites available on this device.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Card shape follows the media, not the page (§5A.1), so a mixed All
|
||||
tab reads as posters, squares and thumbnails side by side. -->
|
||||
<LibraryGrid items={visibleItems} onItemClick={handleItemClick} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user