Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f2506966 | ||
|
|
a818fee297 | ||
|
|
a26a853f01 | ||
|
|
9d099268b9 | ||
|
|
e381d626c1 | ||
|
|
b12e99b7e1 | ||
|
|
dc8b732465 | ||
|
|
b98a530f48 | ||
|
|
b565c4ae6f | ||
|
|
79e10d7485 | ||
|
|
a2dbde5492 | ||
|
|
75cd07a5c0 | ||
|
|
64d07b8940 | ||
|
|
5b810f7fc3 | ||
|
|
1ae213ff39 | ||
|
|
98a6bca645 |
+30
-6
@@ -71,7 +71,10 @@ For a narrative overview of the system design, see
|
|||||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -246,8 +249,21 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
| 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-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-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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -318,6 +334,9 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-058 | - | DR-087 |
|
| UR-058 | - | DR-087 |
|
||||||
| UR-060 | - | DR-090, DR-091 |
|
| UR-060 | - | DR-090, DR-091 |
|
||||||
| UR-061 | - | DR-092 |
|
| UR-061 | - | DR-092 |
|
||||||
|
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||||
|
| UR-063 | - | DR-105 |
|
||||||
|
| UR-064 | - | DR-106 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -408,10 +427,15 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||||
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
|
| UT-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 clears the deferred play/pause so a double tap never pauses | 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** 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 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-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 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-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 |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -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
+25
-2
@@ -688,10 +688,10 @@ A movie has no continuation set, so cast follows the hero directly.
|
|||||||
### 5B.4 Series detail — section order
|
### 5B.4 Series detail — section order
|
||||||
|
|
||||||
```
|
```
|
||||||
Hero (poster, title, metadata, Play / Download)
|
Hero (poster, title, metadata, Resume SxEy / Download / Clear history)
|
||||||
→ Crew links
|
→ Crew links
|
||||||
→ Genre tags
|
→ Genre tags
|
||||||
→ Seasons + episodes (per-season sections)
|
→ Seasons (collapsible; only the current season expanded)
|
||||||
→ Cast
|
→ Cast
|
||||||
→ More Like This
|
→ 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
|
The reason a user opens a series page is to pick an episode; discovery content
|
||||||
is secondary and sits underneath.
|
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
|
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||||
|
|
||||||
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"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: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:deploy": "./scripts/deploy-android.sh",
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
|
|||||||
@@ -18,15 +18,50 @@ echo ""
|
|||||||
# Parse args: build type (debug/release) and optional --clean flag.
|
# Parse args: build type (debug/release) and optional --clean flag.
|
||||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
# 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.
|
# 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"
|
BUILD_TYPE="debug"
|
||||||
CLEAN="${CLEAN:-0}"
|
CLEAN="${CLEAN:-0}"
|
||||||
|
ABI="${ABI:-}"
|
||||||
|
next_is_abi=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
|
if [ "$next_is_abi" = "1" ]; then
|
||||||
|
ABI="$arg"
|
||||||
|
next_is_abi=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--clean) CLEAN=1 ;;
|
--clean) CLEAN=1 ;;
|
||||||
|
--abi) next_is_abi=1 ;;
|
||||||
|
--device) ABI="device" ;;
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
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.
|
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||||
if [ "$CLEAN" = "1" ]; then
|
if [ "$CLEAN" = "1" ]; then
|
||||||
echo "🧹 Clearing build caches (clean build)..."
|
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.
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
./scripts/write-keystore-properties.sh
|
./scripts/write-keystore-properties.sh
|
||||||
echo "📦 Building release APK..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk true
|
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
else
|
else
|
||||||
echo "📦 Building debug APK..."
|
echo "📦 Building debug APK..."
|
||||||
bun run tauri android build --apk true --debug
|
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
|||||||
);
|
);
|
||||||
const defined = countDefinedRequirements(md);
|
const defined = countDefinedRequirements(md);
|
||||||
|
|
||||||
expect(defined.UR).toBe(61);
|
expect(defined.UR).toBe(64);
|
||||||
expect(defined.IR).toBe(29);
|
expect(defined.IR).toBe(29);
|
||||||
expect(defined.DR).toBe(91);
|
expect(defined.DR).toBe(104);
|
||||||
expect(defined.JA).toBe(32);
|
expect(defined.JA).toBe(32);
|
||||||
expect(defined.total).toBe(213);
|
expect(defined.total).toBe(229);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
|||||||
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
||||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
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
|
/// Response for player state queries
|
||||||
#[derive(specta::Type, Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -586,7 +574,6 @@ pub async fn player_play_item(
|
|||||||
pub async fn player_enter_background_audio(
|
pub async fn player_enter_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
|
||||||
item: PlayItemRequest,
|
item: PlayItemRequest,
|
||||||
position_seconds: f64,
|
position_seconds: f64,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@@ -636,17 +623,18 @@ pub async fn player_enter_background_audio(
|
|||||||
session_mgr.start_audio_session(media_item.clone());
|
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
|
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
// 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 _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
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
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -677,21 +665,15 @@ pub async fn player_enter_background_audio(
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_exit_background_audio(
|
pub async fn player_exit_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
|
||||||
) -> Result<f64, String> {
|
) -> 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.
|
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
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
|
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||||
let relative = controller.position();
|
let relative = controller.position();
|
||||||
|
|||||||
@@ -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 HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
/// - Android JNI callback also triggers this logic directly
|
/// - Android JNI callback also triggers this logic directly
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_on_playback_ended(
|
pub async fn player_on_playback_ended(
|
||||||
@@ -242,12 +244,17 @@ 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 {
|
if auto_advance {
|
||||||
controller_arc
|
controller_arc
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use uuid::Uuid;
|
|||||||
use crate::domain::rank_search_results;
|
use crate::domain::rank_search_results;
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||||
|
OnlineRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Repository handle manager
|
/// Repository handle manager
|
||||||
@@ -320,6 +321,71 @@ pub async fn repository_get_next_up_episodes(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.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
|
/// Get recently played audio
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ use commands::{
|
|||||||
remote_session_set_volume,
|
remote_session_set_volume,
|
||||||
remote_session_toggle_mute,
|
remote_session_toggle_mute,
|
||||||
// Repository commands
|
// Repository commands
|
||||||
|
repository_clear_watch_history,
|
||||||
repository_create,
|
repository_create,
|
||||||
repository_destroy,
|
repository_destroy,
|
||||||
repository_get_audio_only_stream_url_for_video,
|
repository_get_audio_only_stream_url_for_video,
|
||||||
@@ -201,6 +202,8 @@ use commands::{
|
|||||||
repository_get_rediscover_albums,
|
repository_get_rediscover_albums,
|
||||||
repository_get_resume_items,
|
repository_get_resume_items,
|
||||||
repository_get_resume_movies,
|
repository_get_resume_movies,
|
||||||
|
repository_get_series_current_episode,
|
||||||
|
repository_get_series_episodes,
|
||||||
repository_get_similar_items,
|
repository_get_similar_items,
|
||||||
repository_get_subtitle_url,
|
repository_get_subtitle_url,
|
||||||
repository_get_video_download_url,
|
repository_get_video_download_url,
|
||||||
@@ -869,6 +872,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_get_latest_items,
|
repository_get_latest_items,
|
||||||
repository_get_resume_items,
|
repository_get_resume_items,
|
||||||
repository_get_next_up_episodes,
|
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_recently_played_audio,
|
||||||
repository_get_resume_movies,
|
repository_get_resume_movies,
|
||||||
repository_get_rediscover_albums,
|
repository_get_rediscover_albums,
|
||||||
@@ -1196,9 +1202,6 @@ pub fn run() {
|
|||||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||||
app.manage(video_settings);
|
app.manage(video_settings);
|
||||||
|
|
||||||
// Background-audio handoff base offset (UR-040).
|
|
||||||
app.manage(commands::player::BackgroundAudioOffset::default());
|
|
||||||
|
|
||||||
// Initialize thumbnail cache
|
// Initialize thumbnail cache
|
||||||
info!("[INIT] Initializing thumbnail cache...");
|
info!("[INIT] Initializing thumbnail cache...");
|
||||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
|
|||||||
@@ -915,39 +915,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
}
|
}
|
||||||
|
|
||||||
if auto_advance {
|
if auto_advance {
|
||||||
// Background audio-only episode: the frontend that normally
|
// Shared with the frontend-invoked command path
|
||||||
// performs the advance (goto /player/<id>) is suspended, so
|
// (player_on_playback_ended) so the two dispatchers cannot
|
||||||
// the backend must load the next episode's audio-only stream
|
// disagree about how a background audio-only episode
|
||||||
// itself — otherwise playback just stops at the boundary.
|
// advances — they did, and the command's copy was missing
|
||||||
let is_bg_audio_episode =
|
// the case entirely. That copy is the one that actually
|
||||||
controller.lock().await.current_is_audio_episode();
|
// decides here: the end reason set at load makes this
|
||||||
if is_bg_audio_episode {
|
// callback's own decision Stop, and the frontend echoes the
|
||||||
log::info!(
|
// resulting PlaybackEnded back into the command.
|
||||||
"[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
|
controller
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||||
}
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+436
-5
@@ -105,7 +105,7 @@ pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String
|
|||||||
}
|
}
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::{debug, error, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
@@ -152,6 +152,32 @@ pub struct PlayerController {
|
|||||||
|
|
||||||
// Auto-play episode counter (session-based, resets on manual play)
|
// Auto-play episode counter (session-based, resets on manual play)
|
||||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||||
|
|
||||||
|
// Base offset (seconds) of the active background-audio handoff.
|
||||||
|
//
|
||||||
|
// The audio-only stream is requested with `StartTimeTicks` = the position the
|
||||||
|
// video was handed off at, so the server makes that point the stream's zero
|
||||||
|
// and the native player reports position RELATIVE to it. Adding this base back
|
||||||
|
// yields the absolute position to resume the video at on the way out.
|
||||||
|
//
|
||||||
|
// Lives on the controller (not beside the command) because the queue and this
|
||||||
|
// offset describe the same stream: whenever the controller loads a different
|
||||||
|
// one — notably the backend-driven advance to the next episode — the base has
|
||||||
|
// to move with it.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040 | DR-052
|
||||||
|
background_audio_base: Arc<Mutex<f64>>,
|
||||||
|
|
||||||
|
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||||
|
//
|
||||||
|
// Webview-rendered media is played by an element the native backend cannot
|
||||||
|
// reach, so the backend's own state() says nothing about it. Tracking the
|
||||||
|
// REPORTED state here is what lets transport (play/pause/toggle) be decided
|
||||||
|
// in Rust for that media instead of the frontend reading `el.paused` off the
|
||||||
|
// DOM — a value that flips transiently while buffering/seeking and caused
|
||||||
|
// competing intents to take opposing actions. `None` means no webview media
|
||||||
|
// is active and the native backend is authoritative. See DR-097.
|
||||||
|
html5_playing: Arc<Mutex<Option<bool>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerController {
|
impl PlayerController {
|
||||||
@@ -174,6 +200,8 @@ impl PlayerController {
|
|||||||
position_throttler,
|
position_throttler,
|
||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
|
background_audio_base: Arc::new(Mutex::new(0.0)),
|
||||||
|
html5_playing: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start background timer thread for sleep timer countdown
|
// Start background timer thread for sleep timer countdown
|
||||||
@@ -476,21 +504,72 @@ impl PlayerController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
|
||||||
|
/// player, so transport must be routed to it rather than the native backend.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-097
|
||||||
|
pub fn is_html5_active(&self) -> bool {
|
||||||
|
self.html5_playing.lock_safe().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the webview element last reported itself as playing. Meaningless
|
||||||
|
/// unless [`Self::is_html5_active`] is true.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-097
|
||||||
|
pub fn html5_is_playing(&self) -> bool {
|
||||||
|
self.html5_playing.lock_safe().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a transport intent to the webview element that is rendering media.
|
||||||
|
fn emit_html5_control(&self, action: &str) {
|
||||||
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
|
emitter.emit(PlayerStatusEvent::ControlCommand {
|
||||||
|
action: action.to_string(),
|
||||||
|
position: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Play/resume playback
|
/// Play/resume playback
|
||||||
pub fn play(&self) -> Result<(), PlayerError> {
|
pub fn play(&self) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play");
|
debug!("[PlayerController] play");
|
||||||
|
// Webview-rendered media: the native backend isn't playing it, so drive
|
||||||
|
// the element via a ControlCommand instead (DR-097).
|
||||||
|
if self.is_html5_active() {
|
||||||
|
self.emit_html5_control("play");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.play()
|
backend.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
pub fn pause(&self) -> Result<(), PlayerError> {
|
pub fn pause(&self) -> Result<(), PlayerError> {
|
||||||
|
if self.is_html5_active() {
|
||||||
|
self.emit_html5_control("pause");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.pause()
|
backend.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle play/pause
|
/// Toggle play/pause.
|
||||||
|
///
|
||||||
|
/// The decision is made HERE, from authoritative state — the reported webview
|
||||||
|
/// state for HTML5-rendered media, or the native backend's state otherwise.
|
||||||
|
/// The frontend must never decide this from the DOM (see DR-097).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-097
|
||||||
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
||||||
|
if self.is_html5_active() {
|
||||||
|
let action = if self.html5_is_playing() {
|
||||||
|
"pause"
|
||||||
|
} else {
|
||||||
|
"play"
|
||||||
|
};
|
||||||
|
self.emit_html5_control(action);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut backend = self.backend.lock_safe();
|
let mut backend = self.backend.lock_safe();
|
||||||
if backend.state().is_playing() {
|
if backend.state().is_playing() {
|
||||||
backend.pause()
|
backend.pause()
|
||||||
@@ -890,6 +969,23 @@ impl PlayerController {
|
|||||||
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
||||||
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
||||||
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
||||||
|
// Track it: this is the authoritative play/pause state for
|
||||||
|
// webview-rendered media, and what transport decisions read (DR-097).
|
||||||
|
// "stopped"/"idle" mean the element is gone, so hand authority back to
|
||||||
|
// the native backend — otherwise music playback would keep emitting
|
||||||
|
// ControlCommands at a element that no longer exists.
|
||||||
|
{
|
||||||
|
let mut tracked = self.html5_playing.lock_safe();
|
||||||
|
*tracked = match state.as_str() {
|
||||||
|
"playing" => Some(true),
|
||||||
|
// "loading" counts as active-but-not-playing so a toggle during
|
||||||
|
// load resolves to "play" rather than falling through to the
|
||||||
|
// native backend.
|
||||||
|
"paused" | "loading" => Some(false),
|
||||||
|
// "stopped"/"idle": element is gone, native backend resumes authority.
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1186,68 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record the base offset of a background-audio handoff (the position the
|
||||||
|
/// video was handed off at, which is the audio stream's zero).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-052
|
||||||
|
pub fn set_background_audio_base(&self, seconds: f64) {
|
||||||
|
*self.background_audio_base.lock_safe() = seconds.max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and clear the background-audio base offset.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-052
|
||||||
|
pub fn take_background_audio_base(&self) -> f64 {
|
||||||
|
let mut base = self.background_audio_base.lock_safe();
|
||||||
|
std::mem::replace(&mut *base, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
|
||||||
|
///
|
||||||
|
/// Single place both end-of-playback dispatchers agree on: the Android JNI
|
||||||
|
/// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
|
||||||
|
/// (`player_on_playback_ended`). They used to each carry their own copy of
|
||||||
|
/// this branch, and the command's copy was missing the background-audio case
|
||||||
|
/// entirely — so an audio-only episode ending while backgrounded only ever
|
||||||
|
/// started a countdown that nothing could act on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-052
|
||||||
|
pub async fn auto_advance_to_next_episode(
|
||||||
|
&self,
|
||||||
|
next_episode: crate::repository::types::MediaItem,
|
||||||
|
countdown_seconds: u32,
|
||||||
|
) {
|
||||||
|
// Background audio-only episode: the countdown only emits ticks — the
|
||||||
|
// advance itself is a `goto('/player/<id>')` in the webview, which cannot
|
||||||
|
// start audio while the app is backgrounded. Load the next episode's
|
||||||
|
// audio-only stream here instead, or playback stalls at the boundary.
|
||||||
|
if self.current_is_audio_episode() {
|
||||||
|
info!(
|
||||||
|
"[PlayerController] Background audio episode — advancing to {} in backend",
|
||||||
|
next_episode.id
|
||||||
|
);
|
||||||
|
match self
|
||||||
|
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => self.emit_queue_changed(),
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"[PlayerController] Background audio advance failed: {} — stopping",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
if let Some(emitter) = self.event_emitter() {
|
||||||
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreground: the frontend drives the advance off the countdown ticks.
|
||||||
|
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||||
|
}
|
||||||
|
|
||||||
/// Advance to the next episode while playing audio-only in the background.
|
/// Advance to the next episode while playing audio-only in the background.
|
||||||
///
|
///
|
||||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||||
@@ -1100,10 +1258,10 @@ impl PlayerController {
|
|||||||
///
|
///
|
||||||
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
||||||
///
|
///
|
||||||
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
|
/// Reached through `auto_advance_to_next_episode`, which gates it on
|
||||||
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
|
/// `current_is_audio_episode()` — only ever true after a background-audio
|
||||||
|
/// handoff (Android), but compiled and unit-tested on every platform.
|
||||||
/// TRACES: UR-040, UR-023 | DR-052
|
/// TRACES: UR-040, UR-023 | DR-052
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
||||||
pub async fn advance_to_next_episode_audio_only(
|
pub async fn advance_to_next_episode_audio_only(
|
||||||
&self,
|
&self,
|
||||||
next_episode_id: &str,
|
next_episode_id: &str,
|
||||||
@@ -1158,6 +1316,13 @@ impl PlayerController {
|
|||||||
server_id: Some(next.server_id.clone()),
|
server_id: Some(next.server_id.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The previous episode's handoff base described the stream we are leaving.
|
||||||
|
// This one is built without StartTimeTicks, so its timeline is already
|
||||||
|
// absolute: clear the base (used to resolve the resume position on the way
|
||||||
|
// back to the foreground) and the lockscreen scrubber's matching shift.
|
||||||
|
self.set_background_audio_base(0.0);
|
||||||
|
let _ = set_lockscreen_position_offset(0.0);
|
||||||
|
|
||||||
self.play_item(media_item).map_err(|e| e.to_string())
|
self.play_item(media_item).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,6 +1654,156 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== HTML5 transport authority (DR-097) =====
|
||||||
|
//
|
||||||
|
// Webview-rendered video is played by an element the native backend cannot
|
||||||
|
// reach, so transport for it must be decided from the state the element
|
||||||
|
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
|
||||||
|
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
|
||||||
|
// flips transiently while buffering/seeking — two intents ~150ms apart read
|
||||||
|
// different values, took opposing actions, and self-sustained a pause loop.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_state_is_tracked_from_reports() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
// No HTML5 media reported yet: the native backend stays authoritative.
|
||||||
|
assert!(!controller.is_html5_active());
|
||||||
|
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
assert!(controller.html5_is_playing());
|
||||||
|
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
assert!(!controller.html5_is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_toggle_from_paused_emits_play_control() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["play".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_toggle_from_playing_emits_pause_control() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["pause".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
|
||||||
|
// The loop signature: two intents in quick succession must NOT both
|
||||||
|
// resolve the same way, and must not produce opposing actions from a
|
||||||
|
// stale read. Rust's own tracked state makes the sequence deterministic
|
||||||
|
// as long as the element reports back between intents.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
// Element confirms the pause it was told to do.
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
controller.toggle_playback().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_play_and_pause_emit_control_commands() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.play().unwrap();
|
||||||
|
controller.pause().unwrap();
|
||||||
|
|
||||||
|
let controls: Vec<_> = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
||||||
|
// When webview video goes away, transport must fall back to the native
|
||||||
|
// backend (music playback must not keep emitting ControlCommands).
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
assert!(controller.is_html5_active());
|
||||||
|
|
||||||
|
controller.report_html5_state("stopped".to_string(), None);
|
||||||
|
assert!(!controller.is_html5_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html5_transport_emits_exactly_one_control_per_intent() {
|
||||||
|
// Guards against a double-drive on platforms where the *backend* is also
|
||||||
|
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
|
||||||
|
// must replace the backend call, not run in addition to it.
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let emitter = Arc::new(CapturingEmitter::new());
|
||||||
|
controller.set_event_emitter(emitter.clone());
|
||||||
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
||||||
|
|
||||||
|
controller.pause().unwrap();
|
||||||
|
|
||||||
|
let controls = emitter
|
||||||
|
.events()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(controls, 1, "one intent must produce exactly one control");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_controller_volume_default() {
|
fn test_controller_volume_default() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
@@ -2547,6 +2862,9 @@ mod tests {
|
|||||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn get_person(
|
async fn get_person(
|
||||||
&self,
|
&self,
|
||||||
_: &str,
|
_: &str,
|
||||||
@@ -2764,6 +3082,119 @@ mod tests {
|
|||||||
assert!(controller.current_is_audio_episode());
|
assert!(controller.current_is_audio_episode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The handoff base offset describes ONE stream: the audio-only URL built
|
||||||
|
/// with `StartTimeTicks` = the position the video was handed off at, whose
|
||||||
|
/// timeline therefore starts at that point. The next episode is loaded from
|
||||||
|
/// its own beginning, so its timeline is already absolute and the base must
|
||||||
|
/// be cleared — otherwise returning to the foreground resolves the resume
|
||||||
|
/// position as `old_base + position_in_new_episode` and the video jumps to a
|
||||||
|
/// point that has nothing to do with what was playing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
// Handed off 20 minutes into the previous episode.
|
||||||
|
controller.set_background_audio_base(1200.0);
|
||||||
|
|
||||||
|
controller
|
||||||
|
.advance_to_next_episode_audio_only("ep2")
|
||||||
|
.await
|
||||||
|
.expect("advance should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
controller.take_background_audio_base(),
|
||||||
|
0.0,
|
||||||
|
"the next episode starts at its own zero, so the previous handoff \
|
||||||
|
base must not survive the advance"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A background audio-only episode must advance IN THE BACKEND when the
|
||||||
|
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
||||||
|
/// countdown the frontend is supposed to act on.
|
||||||
|
///
|
||||||
|
/// The countdown only emits CountdownTick events; the actual advance is a
|
||||||
|
/// `goto('/player/<id>')` in the webview. While the app is backgrounded that
|
||||||
|
/// navigation cannot start audio, so playback stalls at the episode boundary
|
||||||
|
/// with ExoPlayer parked in STATE_ENDED — and any later play intent
|
||||||
|
/// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
|
||||||
|
/// start, which is what surfaces to the user as "the episode randomly
|
||||||
|
/// restarted".
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auto_advance_background_audio_episode_advances_in_backend() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
// Currently playing: ep2 handed off to audio-only background playback.
|
||||||
|
let episode = MediaItem {
|
||||||
|
id: "ep2".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "http://example.com/ep2-audio.mp3".to_string(),
|
||||||
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
|
||||||
|
let next = make_repo_episode("ep3", 3);
|
||||||
|
controller.auto_advance_to_next_episode(next, 10).await;
|
||||||
|
|
||||||
|
let current = controller
|
||||||
|
.queue
|
||||||
|
.lock_safe()
|
||||||
|
.current()
|
||||||
|
.cloned()
|
||||||
|
.expect("an item should still be loaded");
|
||||||
|
assert_eq!(
|
||||||
|
current.id, "ep3",
|
||||||
|
"background audio-only episode must advance in the backend, not wait \
|
||||||
|
for a frontend navigation that cannot happen while backgrounded"
|
||||||
|
);
|
||||||
|
assert_eq!(current.media_type, MediaType::Audio);
|
||||||
|
assert!(controller.current_is_audio_episode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foreground video playback keeps the countdown-driven advance: the frontend
|
||||||
|
/// owns the navigation there, so the backend must NOT load the next episode
|
||||||
|
/// itself (that would race the page transition and double-start playback).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auto_advance_foreground_video_episode_uses_countdown() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
let episode = MediaItem {
|
||||||
|
id: "ep2".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "http://example.com/ep2.m3u8".to_string(),
|
||||||
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
|
||||||
|
let next = make_repo_episode("ep3", 3);
|
||||||
|
controller.auto_advance_to_next_episode(next, 10).await;
|
||||||
|
|
||||||
|
let current = controller
|
||||||
|
.queue
|
||||||
|
.lock_safe()
|
||||||
|
.current()
|
||||||
|
.cloned()
|
||||||
|
.expect("an item should still be loaded");
|
||||||
|
assert_eq!(
|
||||||
|
current.id, "ep2",
|
||||||
|
"foreground video advance is frontend-driven; the backend must not \
|
||||||
|
swap the queue item out from under it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Without a controller repository the Android episode path must still
|
/// Without a controller repository the Android episode path must still
|
||||||
/// stop gracefully (previous behavior) rather than error.
|
/// stop gracefully (previous behavior) rather than error.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -750,6 +750,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.online.unmark_favorite(item_id).await
|
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> {
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let online = Arc::clone(&self.online);
|
let online = Arc::clone(&self.online);
|
||||||
@@ -1128,6 +1133,10 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1386,6 +1395,10 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod hybrid;
|
pub mod hybrid;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod online;
|
pub mod online;
|
||||||
|
pub mod series_progress;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use hybrid::HybridRepository;
|
pub use hybrid::HybridRepository;
|
||||||
@@ -211,6 +212,14 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// Unmark item as favorite
|
/// Unmark item as favorite
|
||||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
async fn unmark_favorite(&self, item_id: &str) -> Result<(), 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
|
/// Get person details
|
||||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||||
|
|
||||||
|
|||||||
@@ -1627,6 +1627,12 @@ impl MediaRepository for OfflineRepository {
|
|||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
|
// Erasing history has to reach the server to be meaningful — clearing
|
||||||
|
// it only locally would be silently undone by the next sync.
|
||||||
|
Err(RepoError::Offline)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"SELECT id, name, overview, primary_image_tag
|
"SELECT id, name, overview, primary_image_tag
|
||||||
|
|||||||
@@ -1691,6 +1691,48 @@ impl MediaRepository for OnlineRepository {
|
|||||||
result
|
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> {
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||||
|
|||||||
@@ -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.)
|
/// 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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserData {
|
pub struct UserData {
|
||||||
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
|
|||||||
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
* - 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
|
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
* - Android JNI callback also triggers this logic directly
|
* - Android JNI callback also triggers this logic directly
|
||||||
|
*
|
||||||
|
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
||||||
*/
|
*/
|
||||||
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
|
||||||
@@ -1265,6 +1267,46 @@ async repositoryGetResumeItems(handle: string, parentId: string | null, limit: n
|
|||||||
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
|
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
|
||||||
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
|
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
|
* Get recently played audio
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -137,6 +137,37 @@ export class RepositoryClient {
|
|||||||
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
|
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[]> {
|
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
|
||||||
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
|
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { Library, MediaItem } from "$lib/api/types";
|
import type { Library, MediaItem } from "$lib/api/types";
|
||||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||||
|
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
|
||||||
import { formatBytes } from "$lib/utils/formatBytes";
|
import { formatBytes } from "$lib/utils/formatBytes";
|
||||||
import {
|
import {
|
||||||
downloadedCatalog,
|
downloadedCatalog,
|
||||||
@@ -62,6 +63,16 @@
|
|||||||
void openLibrary(item as Library);
|
void openLibrary(item as Library);
|
||||||
return;
|
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}`);
|
goto(`/library/${item.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 @@
|
|||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
|
import {
|
||||||
|
isCurrentEpisode as isSameEpisode,
|
||||||
|
adjacentEpisodes as computeAdjacent,
|
||||||
|
stripCardLabel,
|
||||||
|
} from "./episodeStrip";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
episode: MediaItem;
|
episode: MediaItem;
|
||||||
@@ -245,8 +249,8 @@
|
|||||||
<!-- Episode info -->
|
<!-- Episode info -->
|
||||||
<div class="mt-2 space-y-1">
|
<div class="mt-2 space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
|
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
|
||||||
{ep.indexNumber || 0}.
|
{stripCardLabel(ep, episode)}
|
||||||
</span>
|
</span>
|
||||||
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
||||||
{ep.name}
|
{ep.name}
|
||||||
|
|||||||
@@ -10,15 +10,21 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
episode: MediaItem;
|
episode: MediaItem;
|
||||||
focused?: boolean;
|
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;
|
onclick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { episode, focused = false, onclick }: Props = $props();
|
let { episode, focused = false, current = false, onclick }: Props = $props();
|
||||||
|
|
||||||
let buttonRef: HTMLButtonElement | null = null;
|
let buttonRef: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (focused && buttonRef) {
|
if ((focused || current) && buttonRef) {
|
||||||
// Scroll into view with some offset from top
|
// Scroll into view with some offset from top
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
@@ -51,7 +57,11 @@
|
|||||||
<button
|
<button
|
||||||
bind:this={buttonRef}
|
bind:this={buttonRef}
|
||||||
type="button"
|
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}
|
{onclick}
|
||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
@@ -137,6 +147,13 @@
|
|||||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{truncateMiddle(episode.name, 56)}
|
{truncateMiddle(episode.name, 56)}
|
||||||
</h3>
|
</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 -->
|
<!-- Played indicator -->
|
||||||
{#if episode.userData?.isPlayed}
|
{#if episode.userData?.isPlayed}
|
||||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
<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 {
|
interface Props {
|
||||||
config: GenreConfig;
|
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 genres = $state<Genre[]>([]);
|
||||||
let filteredGenres = $state<Genre[]>([]);
|
let filteredGenres = $state<Genre[]>([]);
|
||||||
@@ -153,7 +160,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- 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">
|
<div class="flex items-center gap-4">
|
||||||
<BackButton onClick={goBack} label="Back" />
|
<BackButton onClick={goBack} label="Back" />
|
||||||
<h1 class="text-3xl font-bold text-white">
|
<h1 class="text-3xl font-bold text-white">
|
||||||
@@ -164,6 +173,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !selectedGenre}
|
{#if !selectedGenre}
|
||||||
<!-- Genre Browser -->
|
<!-- Genre Browser -->
|
||||||
|
|||||||
@@ -41,9 +41,15 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: MediaListConfig;
|
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 items = $state<MediaItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -246,10 +252,12 @@
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
{#if showHeader}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<BackButton onClick={goBack} label="Back" />
|
<BackButton onClick={goBack} label="Back" />
|
||||||
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Search and Sort Bar -->
|
<!-- Search and Sort Bar -->
|
||||||
<div class="flex flex-col sm:flex-row gap-4">
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import type { MediaKind } from "$lib/api/types";
|
import type { MediaKind } from "$lib/api/types";
|
||||||
|
import { libraryViewUrl } from "$lib/utils/libraryView";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
genres: string[];
|
genres: string[];
|
||||||
@@ -17,7 +18,9 @@
|
|||||||
itemKind
|
itemKind
|
||||||
}: Props = $props();
|
}: 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 {
|
function genreBasePath(kind: MediaKind | undefined): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "album":
|
case "album":
|
||||||
@@ -28,11 +31,11 @@
|
|||||||
case "series":
|
case "series":
|
||||||
case "season":
|
case "season":
|
||||||
case "episode":
|
case "episode":
|
||||||
return "/library/shows/genres";
|
return libraryViewUrl("/library/tv", "genres");
|
||||||
case "movie":
|
case "movie":
|
||||||
return "/library/movies/genres";
|
return libraryViewUrl("/library/movies", "genres");
|
||||||
default:
|
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,26 +1,56 @@
|
|||||||
|
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import EpisodeRow from "./EpisodeRow.svelte";
|
import EpisodeRow from "./EpisodeRow.svelte";
|
||||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||||
|
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import { seasonAnchorId } from "./seriesNavigation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
season: MediaItem;
|
season: MediaItem;
|
||||||
episodes: MediaItem[];
|
episodes: MediaItem[];
|
||||||
focusedEpisodeId?: string;
|
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;
|
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 episodeCount = $derived(episodes.length);
|
||||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
|
||||||
const seasonName = $derived(
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4 scroll-mt-4" id={anchor}>
|
||||||
<!-- Season header -->
|
<!-- Season header -->
|
||||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||||
<!-- Season poster -->
|
<!-- Season poster -->
|
||||||
@@ -38,28 +68,60 @@
|
|||||||
<!-- Season info -->
|
<!-- Season info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<!-- The whole title block toggles the season open/closed. -->
|
||||||
<h2 class="text-xl font-bold text-white">
|
<button
|
||||||
{seasonName}
|
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>
|
</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>
|
<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}
|
{#if season.productionYear}
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{season.productionYear}</span>
|
<span>{season.productionYear}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if season.overview}
|
{#if season.overview && expanded}
|
||||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
|
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
|
||||||
{season.overview}
|
{season.overview}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Download Season Button -->
|
<!-- Per-season actions -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0 flex items-center gap-2">
|
||||||
<SeasonDownloadButton
|
<SeasonDownloadButton
|
||||||
seasonId={season.id}
|
seasonId={season.id}
|
||||||
seriesName={season.seriesName || ""}
|
seriesName={season.seriesName || ""}
|
||||||
@@ -68,19 +130,29 @@
|
|||||||
{episodeCount}
|
{episodeCount}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<ClearHistoryButton
|
||||||
|
itemId={season.id}
|
||||||
|
itemName={seasonName}
|
||||||
|
scope="season"
|
||||||
|
size="sm"
|
||||||
|
onCleared={onHistoryCleared}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Episode list -->
|
<!-- Episode list -->
|
||||||
<div class="space-y-1 pl-2">
|
{#if expanded}
|
||||||
|
<div class="space-y-1 pl-2" id="{anchor}-episodes">
|
||||||
{#each episodes as episode (episode.id)}
|
{#each episodes as episode (episode.id)}
|
||||||
<EpisodeRow
|
<EpisodeRow
|
||||||
{episode}
|
{episode}
|
||||||
focused={episode.id === focusedEpisodeId}
|
focused={episode.id === focusedEpisodeId}
|
||||||
|
current={episode.id === currentEpisodeId}
|
||||||
onclick={() => onEpisodeClick?.(episode)}
|
onclick={() => onEpisodeClick?.(episode)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
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.
|
// Minimal episode factory — only the fields the strip logic reads.
|
||||||
function ep(
|
function ep(
|
||||||
@@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => {
|
|||||||
expect(strip).toContain(current);
|
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 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);
|
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)", () => {
|
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 current = ep("mystery", null, 3); // no season number
|
||||||
const strip = adjacentEpisodes(current, eps);
|
const strip = adjacentEpisodes(current, eps);
|
||||||
expect(strip.length).toBeGreaterThan(1);
|
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.
|
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
|
||||||
//
|
//
|
||||||
// Extracted from the component so it can be unit-tested: the strip must never
|
// 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
|
// collapse to just the current episode while real siblings exist, must not
|
||||||
// not mistake number-less episodes for the current one.
|
// 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";
|
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`?
|
* 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
|
* The window of episodes shown under the hero: up to 3 before and 6 after the
|
||||||
* current episode. Degrades gracefully:
|
* current episode, in series order across *all* seasons.
|
||||||
* - 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);
|
* Crossing a season boundary is the point (ux-flows §5B.2): finishing a season
|
||||||
* - splices the current episode into the pool at its numeric position when it
|
* finale should offer the next season's premiere, not an empty strip. Degrades
|
||||||
* isn't present, so it still anchors the window;
|
* 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.
|
* - returns just `[current]` only when there genuinely are no other episodes.
|
||||||
*/
|
*/
|
||||||
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
|
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
|
||||||
const seasonMatches = allEpisodes.filter(
|
const pool = allEpisodes.slice().sort(compareSeriesOrder);
|
||||||
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
|
|
||||||
);
|
|
||||||
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
|
|
||||||
|
|
||||||
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
|
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
|
||||||
|
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
const epNum = current.indexNumber ?? 0;
|
const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0);
|
||||||
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
|
|
||||||
idx = insertAt === -1 ? pool.length : insertAt;
|
idx = insertAt === -1 ? pool.length : insertAt;
|
||||||
pool.splice(idx, 0, current);
|
pool.splice(idx, 0, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = Math.max(0, idx - 3);
|
const start = Math.max(0, idx - BEFORE);
|
||||||
const end = Math.min(pool.length, idx + 7);
|
const end = Math.min(pool.length, idx + AFTER + 1);
|
||||||
return pool.slice(start, end);
|
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;
|
||||||
|
}
|
||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, untrack } from "svelte";
|
import { onMount, onDestroy, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
resolveSeekTarget,
|
resolveSeekTarget,
|
||||||
|
clampSeekTarget,
|
||||||
|
isSynthesizedTouchClick,
|
||||||
|
isControlSurfaceTouch,
|
||||||
SEEK_FORWARD_SECONDS,
|
SEEK_FORWARD_SECONDS,
|
||||||
SEEK_BACKWARD_SECONDS,
|
SEEK_BACKWARD_SECONDS,
|
||||||
type TapFeedback,
|
type TapFeedback,
|
||||||
@@ -111,7 +114,9 @@
|
|||||||
let touchStartY = $state(0);
|
let touchStartY = $state(0);
|
||||||
let touchStartTime = $state(0);
|
let touchStartTime = $state(0);
|
||||||
let tapGestures = createTapGestureState();
|
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 brightness = $state(1); // 0-2, default 1
|
||||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | 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.
|
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||||
let pendingSeekTarget: number | null = null;
|
let pendingSeekTarget: number | null = null;
|
||||||
let swipeGestureActive = $state(false);
|
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)
|
// 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
|
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)}]`);
|
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[VideoPlayer Debug]", {
|
// Flattened to a single string on purpose: the Android WebView console
|
||||||
currentTime: videoElement.currentTime.toFixed(2),
|
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||||
displayTime: currentTime.toFixed(2),
|
// this whole payload useless when diagnosing over adb.
|
||||||
buffered: bufferedRanges.join(", "),
|
console.log(
|
||||||
readyState: videoElement.readyState,
|
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
||||||
paused: videoElement.paused,
|
` display=${currentTime.toFixed(2)}` +
|
||||||
seeking: videoElement.seeking,
|
` readyState=${videoElement.readyState}` +
|
||||||
playbackRate: videoElement.playbackRate,
|
` networkState=${videoElement.networkState}` +
|
||||||
});
|
` paused=${videoElement.paused}` +
|
||||||
|
` seeking=${videoElement.seeking}` +
|
||||||
|
` rate=${videoElement.playbackRate}` +
|
||||||
|
` buffered=${bufferedRanges.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
@@ -714,11 +731,6 @@
|
|||||||
if (debugLogInterval) {
|
if (debugLogInterval) {
|
||||||
clearInterval(debugLogInterval);
|
clearInterval(debugLogInterval);
|
||||||
}
|
}
|
||||||
// A deferred single tap must not fire play/pause after teardown.
|
|
||||||
if (tapTimeout) {
|
|
||||||
clearTimeout(tapTimeout);
|
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
tapGestures.cancel();
|
tapGestures.cancel();
|
||||||
if (doubleTapFeedbackTimeout) {
|
if (doubleTapFeedbackTimeout) {
|
||||||
clearTimeout(doubleTapFeedbackTimeout);
|
clearTimeout(doubleTapFeedbackTimeout);
|
||||||
@@ -1100,6 +1112,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePause() {
|
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;
|
isPlaying = false;
|
||||||
stopTimeUpdates(); // Stop RAF loop when paused
|
stopTimeUpdates(); // Stop RAF loop when paused
|
||||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||||
@@ -1143,11 +1170,33 @@
|
|||||||
const targetTime = parseFloat(input.value);
|
const targetTime = parseFloat(input.value);
|
||||||
// Update the displayed time immediately for smooth visual feedback
|
// Update the displayed time immediately for smooth visual feedback
|
||||||
currentTime = targetTime;
|
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;
|
* Seek-bar released — commit the value the user landed on, at most once.
|
||||||
const targetTime = parseFloat(input.value);
|
*
|
||||||
|
* 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
|
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
@@ -1384,16 +1433,9 @@
|
|||||||
to: newTime.toFixed(2),
|
to: newTime.toFixed(2),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call the unified handleSeekBarChange logic with the new time
|
// Same commit path as the seek bar — one place decides how a seek is issued.
|
||||||
// Create a synthetic event to reuse the existing logic
|
|
||||||
const syntheticEvent = {
|
|
||||||
target: {
|
|
||||||
value: newTime.toString()
|
|
||||||
}
|
|
||||||
} as unknown as Event;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleSeekBarChange(syntheticEvent);
|
await commitSeek(newTime);
|
||||||
} finally {
|
} finally {
|
||||||
// The player is authoritative again from here on.
|
// The player is authoritative again from here on.
|
||||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
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
|
// Touch gesture handlers
|
||||||
function handleTouchStart(e: TouchEvent) {
|
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];
|
const touch = e.touches[0];
|
||||||
touchStartX = touch.clientX;
|
touchStartX = touch.clientX;
|
||||||
touchStartY = touch.clientY;
|
touchStartY = touch.clientY;
|
||||||
@@ -1434,28 +1515,29 @@
|
|||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tapTimeout) {
|
// Suppress the compatibility click this touch will synthesize.
|
||||||
clearTimeout(tapTimeout);
|
lastTouchTapAt = Date.now();
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.action === "seek") {
|
if (outcome.action === "seek") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single tap so far: defer play/pause until the double-tap window closes,
|
// First tap: act now. Nothing is deferred, so there is no timer to race the
|
||||||
// so a double tap seeks without also toggling pause.
|
// compatibility click Android synthesizes after a touch tap (see DR-098).
|
||||||
tapTimeout = setTimeout(() => {
|
|
||||||
tapTimeout = null;
|
|
||||||
if (tapGestures.resolvePending(Date.now())) {
|
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
}, outcome.pendingAfterMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
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;
|
if (!e.touches[0]) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
@@ -1465,14 +1547,16 @@
|
|||||||
|
|
||||||
// Minimum movement to register as swipe (50px)
|
// Minimum movement to register as swipe (50px)
|
||||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||||
swipeGestureActive = true;
|
// 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.
|
||||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
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();
|
tapGestures.cancel();
|
||||||
if (tapTimeout) {
|
|
||||||
clearTimeout(tapTimeout);
|
|
||||||
tapTimeout = null;
|
|
||||||
}
|
}
|
||||||
|
swipeGestureActive = true;
|
||||||
|
|
||||||
// Brightness control on vertical swipe
|
// Brightness control on vertical swipe
|
||||||
swipeType = "brightness";
|
swipeType = "brightness";
|
||||||
@@ -1486,19 +1570,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd(e: TouchEvent) {
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
playerGestureActive = false;
|
||||||
swipeGestureActive = false;
|
swipeGestureActive = false;
|
||||||
swipeType = null;
|
swipeType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
|
||||||
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
* `handleTouchStart`, so the compatibility click the browser synthesizes after
|
||||||
* so the compatibility click that follows a tap must be ignored here —
|
* a tap must be ignored or every tap toggles twice.
|
||||||
* otherwise it pauses on the first tap of a double tap.
|
*
|
||||||
|
* 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) {
|
function handleSurfaceClick(e: MouseEvent) {
|
||||||
// A click synthesized from a touch reports no pointer movement detail.
|
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
|
||||||
if (e.detail === 0 || tapTimeout !== null) return;
|
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,7 +1753,7 @@
|
|||||||
onwaiting={handleWaiting}
|
onwaiting={handleWaiting}
|
||||||
onplaying={handlePlaying}
|
onplaying={handlePlaying}
|
||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={handleVideoClick}
|
onclick={handleSurfaceClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!-- Temporarily disabled to debug playback issues
|
||||||
{#each subtitleTracks() as track}
|
{#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 class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !isPlaying}
|
{: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
|
<button
|
||||||
|
data-player-surface
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||||
onclick={togglePlayPause}
|
onclick={handleSurfaceClick}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1810,8 +1904,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||||
|
container-level tap gestures ignore touches here (see DR-098). -->
|
||||||
<div
|
<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"
|
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
||||||
class:opacity-0={!showControls}
|
class:opacity-0={!showControls}
|
||||||
class:pointer-events-none={!showControls}
|
class:pointer-events-none={!showControls}
|
||||||
@@ -1838,11 +1934,11 @@
|
|||||||
max={duration || 100}
|
max={duration || 100}
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
oninput={handleSeekBarInput}
|
oninput={handleSeekBarInput}
|
||||||
onchange={handleSeekBarChange}
|
onchange={handleSeekBarRelease}
|
||||||
onmousedown={() => isDraggingSeekBar = true}
|
onmousedown={() => isDraggingSeekBar = true}
|
||||||
onmouseup={() => isDraggingSeekBar = false}
|
onmouseup={handleSeekBarRelease}
|
||||||
ontouchstart={() => isDraggingSeekBar = true}
|
ontouchstart={() => isDraggingSeekBar = true}
|
||||||
ontouchend={() => isDraggingSeekBar = false}
|
ontouchend={handleSeekBarRelease}
|
||||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
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]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
[&::-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,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
resolveSeekTarget,
|
resolveSeekTarget,
|
||||||
|
clampSeekTarget,
|
||||||
|
END_SEEK_MARGIN_SECONDS,
|
||||||
|
isSynthesizedTouchClick,
|
||||||
|
isControlSurfaceTouch,
|
||||||
|
TOUCH_CLICK_SUPPRESS_MS,
|
||||||
} from "./tapGestures";
|
} from "./tapGestures";
|
||||||
|
|
||||||
const SCREEN_WIDTH = 1000;
|
const SCREEN_WIDTH = 1000;
|
||||||
@@ -25,24 +30,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("tap gesture resolution", () => {
|
describe("tap gesture resolution", () => {
|
||||||
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
|
||||||
const state = createTapGestureState();
|
//
|
||||||
const first = tap(state, RIGHT, 1000);
|
// 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
|
it("toggles play/pause immediately on the first tap", () => {
|
||||||
// become a double tap.
|
const state = createTapGestureState();
|
||||||
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
|
||||||
const state = createTapGestureState();
|
|
||||||
tap(state, RIGHT, 1000);
|
|
||||||
|
|
||||||
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
|
||||||
expect(resolved).toEqual({ action: "togglePlayPause" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
const second = asSeek(tap(state, RIGHT, 1150));
|
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(SEEK_FORWARD_SECONDS);
|
||||||
expect(second.seekSeconds).toBe(30);
|
expect(second.seekSeconds).toBe(30);
|
||||||
expect(second.feedback).toBe("right");
|
expect(second.feedback).toBe("right");
|
||||||
|
// The re-toggle is what preserves the play state across a double tap.
|
||||||
// The deferred single-tap pause must have been cancelled.
|
expect(second.togglePlayPause).toBe(true);
|
||||||
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
const state = createTapGestureState();
|
||||||
tap(state, LEFT, 1000);
|
tap(state, LEFT, 1000);
|
||||||
const second = asSeek(tap(state, LEFT, 1100));
|
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(SEEK_BACKWARD_SECONDS);
|
||||||
expect(second.seekSeconds).toBe(-10);
|
expect(second.seekSeconds).toBe(-10);
|
||||||
expect(second.feedback).toBe("left");
|
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();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
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();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||||
|
|
||||||
// Triple tap: the third tap starts a fresh pending tap rather than
|
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
|
||||||
// seeking again off the consumed second tap.
|
// play/pause — there is no "third tap" concept.
|
||||||
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates repeated double taps on the same side", () => {
|
it("accumulates repeated double taps on the same side", () => {
|
||||||
@@ -103,12 +125,13 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.feedback).toBe("right");
|
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();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
state.cancel();
|
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);
|
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps to the duration when skipping past the end", () => {
|
it("clamps short of the duration when skipping past the end", () => {
|
||||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
// 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", () => {
|
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);
|
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.
|
* Tap-gesture interpretation for the video player surface.
|
||||||
*
|
*
|
||||||
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
|
||||||
* a tap cannot be classified at the moment it lands, because it may still turn
|
* deferral:
|
||||||
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
|
||||||
* until the double-tap window closes, and cancelled outright if a second tap
|
|
||||||
* arrives — otherwise a double tap both toggles pause and seeks.
|
|
||||||
*
|
*
|
||||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
* 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;
|
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. */
|
/** Double tap on the right half: skip forward. */
|
||||||
export const SEEK_FORWARD_SECONDS = 30;
|
export const SEEK_FORWARD_SECONDS = 30;
|
||||||
|
|
||||||
@@ -22,9 +90,18 @@ export const SEEK_BACKWARD_SECONDS = -10;
|
|||||||
export type TapFeedback = "left" | "right";
|
export type TapFeedback = "left" | "right";
|
||||||
|
|
||||||
export type TapOutcome =
|
export type TapOutcome =
|
||||||
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
/** First tap: toggle play/pause right now. */
|
||||||
| { action: "pending"; pendingAfterMs: number }
|
| { action: "togglePlayPause" }
|
||||||
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
/**
|
||||||
|
* 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 {
|
export interface TapInput {
|
||||||
/** Tap x position, viewport pixels. */
|
/** Tap x position, viewport pixels. */
|
||||||
@@ -35,32 +112,20 @@ export interface TapInput {
|
|||||||
|
|
||||||
export interface TapGestureState {
|
export interface TapGestureState {
|
||||||
/**
|
/**
|
||||||
* Resolve a still-pending single tap. Returns the play/pause action once the
|
* Forget the previous tap, so the next one is treated as a first tap. Used
|
||||||
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
* when the gesture turns out to be a swipe.
|
||||||
* became a double tap, or was cancelled).
|
|
||||||
*/
|
*/
|
||||||
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
|
||||||
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
|
||||||
cancel(): void;
|
cancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalState extends TapGestureState {
|
interface InternalState extends TapGestureState {
|
||||||
lastTapTime: number;
|
lastTapTime: number;
|
||||||
pendingSince: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTapGestureState(): TapGestureState {
|
export function createTapGestureState(): TapGestureState {
|
||||||
const state: InternalState = {
|
const state: InternalState = {
|
||||||
lastTapTime: 0,
|
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() {
|
cancel() {
|
||||||
state.pendingSince = null;
|
|
||||||
state.lastTapTime = 0;
|
state.lastTapTime = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -68,27 +133,64 @@ export function createTapGestureState(): TapGestureState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
* Classify a tap and return the action to perform *now*.
|
||||||
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
*
|
||||||
* the window returns the seek and clears the pending play/pause.
|
* 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 {
|
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||||
const s = state as InternalState;
|
const s = state as InternalState;
|
||||||
const sinceLastTap = input.now - s.lastTapTime;
|
const sinceLastTap = input.now - s.lastTapTime;
|
||||||
|
|
||||||
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||||
// Second tap: cancel the deferred play/pause and seek instead.
|
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
|
||||||
s.pendingSince = null;
|
|
||||||
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
|
||||||
const isLeftSide = input.x < input.screenWidth / 2;
|
const isLeftSide = input.x < input.screenWidth / 2;
|
||||||
return isLeftSide
|
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.lastTapTime = input.now;
|
||||||
s.pendingSince = input.now;
|
return { action: "togglePlayPause" };
|
||||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export interface SeekTargetInput {
|
||||||
@@ -123,6 +225,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
|||||||
|
|
||||||
const target = base + delta;
|
const target = base + delta;
|
||||||
if (target < 0) return 0;
|
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;
|
return target;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
expect(video.play).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("pause() calls element.pause()", async () => {
|
||||||
video.paused = false;
|
video.paused = false;
|
||||||
await adapter.pause();
|
await adapter.pause();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* intents flowing through the PlayerAdapter interface while preserving the
|
* intents flowing through the PlayerAdapter interface while preserving the
|
||||||
* hard-won element behavior verbatim.
|
* 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";
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
|||||||
getMediaSourceId(): string | null;
|
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 {
|
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||||
readonly kind = "html5" as const;
|
readonly kind = "html5" as const;
|
||||||
|
|
||||||
private attachedElement: HTMLVideoElement | null = null;
|
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 host: AdapterHost;
|
||||||
private bridge: Html5ElementBridge;
|
private bridge: Html5ElementBridge;
|
||||||
|
|
||||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
async play(): Promise<void> {
|
async play(): Promise<void> {
|
||||||
const el = this.element;
|
const el = this.element;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// 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 {
|
try {
|
||||||
await el.play();
|
await el.play();
|
||||||
// handlePlay on the element reports "playing"; no double-report here.
|
// handlePlay on the element reports "playing"; no double-report here.
|
||||||
} catch (err) {
|
} 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}`);
|
this.host.onError(`play() failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this.pendingPlay = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.pendingPlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
|
|||||||
+15
-4
@@ -12,7 +12,7 @@
|
|||||||
* derived + merged (remote-session-aware) stores so UI can import state and
|
* derived + merged (remote-session-aware) stores so UI can import state and
|
||||||
* actions from one place, in both local and remote modes.
|
* 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";
|
import { get } from "svelte/store";
|
||||||
@@ -83,18 +83,29 @@ function requireHandle(): string {
|
|||||||
// Transport controls (no repository handle required)
|
// 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() {
|
async function play() {
|
||||||
if (activeAdapter) return void (await activeAdapter.play());
|
|
||||||
await commands.playerPlay();
|
await commands.playerPlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pause() {
|
async function pause() {
|
||||||
if (activeAdapter) return void (await activeAdapter.pause());
|
|
||||||
await commands.playerPause();
|
await commands.playerPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
if (activeAdapter) return void (await activeAdapter.toggle());
|
|
||||||
await commands.playerToggle();
|
await commands.playerToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* frontend stores accordingly. This enables push-based updates instead
|
* frontend stores accordingly. This enables push-based updates instead
|
||||||
* of polling.
|
* 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";
|
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
@@ -306,9 +306,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Route a backend-originated control command to the active player adapter, so a
|
* Route a backend-originated control command to the active player adapter, so a
|
||||||
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
|
* backend intent can drive the webview <video>/<audio> element that Rust cannot
|
||||||
* that Rust cannot reach directly. No-op when no video adapter is active (audio
|
* reach directly. No-op when no adapter is active (native playback is already
|
||||||
* playback is already fully backend-driven).
|
* 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 {
|
function handleControlCommand(action: string, position: number | null): void {
|
||||||
const adapter = playerController.getActiveAdapter();
|
const adapter = playerController.getActiveAdapter();
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -42,7 +42,9 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
|||||||
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
|
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
|
||||||
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
|
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
|
||||||
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
|
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";
|
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
|
||||||
|
|
||||||
return "all";
|
return "all";
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
|
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
|
||||||
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
||||||
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.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 VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
|
||||||
import CastSection from "$lib/components/library/CastSection.svelte";
|
import CastSection from "$lib/components/library/CastSection.svelte";
|
||||||
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
|
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
|
||||||
@@ -28,17 +29,27 @@
|
|||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||||
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
|
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
|
||||||
|
import {
|
||||||
interface SeasonData {
|
groupEpisodesBySeason,
|
||||||
season: MediaItem;
|
seasonAnchorId,
|
||||||
episodes: MediaItem[];
|
seasonRedirectTarget,
|
||||||
}
|
episodeFocusHref,
|
||||||
|
seriesPlayHref,
|
||||||
|
seriesPlayLabel,
|
||||||
|
initialExpandedSeasons,
|
||||||
|
type SeasonData,
|
||||||
|
} from "$lib/components/library/seriesNavigation";
|
||||||
|
|
||||||
let item = $state<MediaItem | null>(null);
|
let item = $state<MediaItem | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let seasonData = $state<SeasonData[]>([]);
|
let seasonData = $state<SeasonData[]>([]);
|
||||||
let directFetchedEpisode = $state<MediaItem | null>(null);
|
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
|
// Track if we've done an initial load and previous server state
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = false;
|
||||||
@@ -81,10 +92,25 @@
|
|||||||
error = null;
|
error = null;
|
||||||
seasonData = [];
|
seasonData = [];
|
||||||
directFetchedEpisode = null;
|
directFetchedEpisode = null;
|
||||||
|
currentEpisode = null;
|
||||||
|
expandedSeasons = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
item = await library.loadItem(itemId);
|
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] ✓ Loaded item: ${item?.name} (${item?.kind})`);
|
||||||
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||||
if (item?.people) {
|
if (item?.people) {
|
||||||
@@ -129,59 +155,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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") {
|
if (item?.kind === "series") {
|
||||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||||
|
|
||||||
// Load episodes for each season in parallel
|
const [episodes, current] = await Promise.all([
|
||||||
const seasonDataPromises = seasons.map(async (season) => {
|
repo.getSeriesEpisodes(itemId),
|
||||||
const result = await repo.getItems(season.id, { limit: 100 });
|
// Best-effort: a series still renders if the anchor cannot be resolved.
|
||||||
const episodes = result.items
|
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
||||||
.filter((i) => i.kind === "episode")
|
console.warn("Could not resolve the current episode:", e);
|
||||||
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
|
return null;
|
||||||
return { season, episodes };
|
}),
|
||||||
});
|
]);
|
||||||
|
|
||||||
seasonData = await Promise.all(seasonDataPromises);
|
seasonData = groupEpisodesBySeason(seasons, episodes);
|
||||||
// Sort seasons by index number
|
currentEpisode = current;
|
||||||
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
|
// Open only the season the viewer is in (DR-107).
|
||||||
|
expandedSeasons = initialExpandedSeasons(
|
||||||
// Some series expose episodes directly as children rather than under
|
seasonData,
|
||||||
// season folders. In that case the season fetch above yields nothing —
|
current?.id,
|
||||||
// group the flat episode children by their season number so the Episode
|
$page.url.searchParams.get("episode")
|
||||||
// Focus View still has a populated `allEpisodes` (otherwise "More
|
);
|
||||||
// Episodes" collapses to just the current episode).
|
|
||||||
if (seasonData.every((s) => s.episodes.length === 0)) {
|
|
||||||
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
|
|
||||||
if (flatEpisodes.length > 0) {
|
|
||||||
const bySeason = new Map<number, MediaItem[]>();
|
|
||||||
for (const ep of flatEpisodes) {
|
|
||||||
const key = ep.parentIndexNumber ?? 1;
|
|
||||||
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
|
|
||||||
}
|
|
||||||
seasonData = [...bySeason.entries()]
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([seasonNumber, episodes]) => ({
|
|
||||||
// Synthesize a minimal season header from the episodes we have.
|
|
||||||
season: {
|
|
||||||
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
|
|
||||||
kind: "season",
|
|
||||||
indexNumber: seasonNumber,
|
|
||||||
name: `Season ${seasonNumber}`,
|
|
||||||
} as MediaItem,
|
|
||||||
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a focused episode ID but couldn't find it in the seasons,
|
// If we have a focused episode ID but couldn't find it in the seasons,
|
||||||
// fetch it directly (handles ID mismatch between APIs)
|
// fetch it directly (handles ID mismatch between APIs)
|
||||||
const episodeIdParam = $page.url.searchParams.get("episode");
|
const episodeIdParam = $page.url.searchParams.get("episode");
|
||||||
if (episodeIdParam) {
|
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
|
||||||
const allEps = seasonData.flatMap((s) => s.episodes);
|
|
||||||
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
|
|
||||||
if (!foundInSeasons) {
|
|
||||||
try {
|
try {
|
||||||
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -189,7 +194,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Failed to load item";
|
error = e instanceof Error ? e.message : "Failed to load item";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -224,14 +228,21 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (clickedItem.kind) {
|
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":
|
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 "album":
|
||||||
case "artist":
|
case "artist":
|
||||||
case "folder":
|
case "folder":
|
||||||
case "playlist":
|
case "playlist":
|
||||||
case "channel":
|
case "channel":
|
||||||
case "episode":
|
|
||||||
case "movie":
|
case "movie":
|
||||||
goto(`/library/${clickedItem.id}`);
|
goto(`/library/${clickedItem.id}`);
|
||||||
break;
|
break;
|
||||||
@@ -244,15 +255,29 @@
|
|||||||
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
|
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
|
||||||
// This fixes Android playback issues where navigation-based approach was hanging
|
// 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) {
|
function handleEpisodeClick(episode: MediaItem) {
|
||||||
// Play the episode with the series queued for next episode
|
// Swap focus to the episode in place; playback starts from the focus view's
|
||||||
goto(`/player/${episode.id}`);
|
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
|
||||||
|
goto(episodeFocusHref(episode));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePlayAll() {
|
async function handlePlayAll() {
|
||||||
// For single items (Episode, Movie), play the item directly
|
// For single items (Episode, Movie), play the item directly
|
||||||
if (item?.kind === "episode" || item?.kind === "movie") {
|
if (item?.kind === "episode" || item?.kind === "movie") {
|
||||||
goto(`/player/${itemId}`);
|
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) {
|
} else if (item?.kind === "album" && $libraryItems.length > 0) {
|
||||||
// For albums, use the backend command (backend fetches and queues all tracks)
|
// For albums, use the backend command (backend fetches and queues all tracks)
|
||||||
try {
|
try {
|
||||||
@@ -293,6 +318,11 @@
|
|||||||
console.error("Failed to shuffle play album:", e);
|
console.error("Failed to shuffle play album:", e);
|
||||||
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
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) {
|
} else if ($libraryItems.length > 0) {
|
||||||
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
|
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
|
||||||
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
|
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
|
||||||
@@ -304,6 +334,10 @@
|
|||||||
seasonData.flatMap((s) => s.episodes)
|
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)
|
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
|
||||||
const focusedEpisode = $derived(
|
const focusedEpisode = $derived(
|
||||||
focusedEpisodeId
|
focusedEpisodeId
|
||||||
@@ -422,9 +456,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if item.parentIndexNumber || item.indexNumber}
|
{#if item.parentIndexNumber || item.indexNumber}
|
||||||
<p class="text-lg text-gray-400 mt-1">
|
<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
|
<a
|
||||||
href={`/library/${item.seasonId}`}
|
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
|
||||||
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
|
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
|
||||||
>Season {item.parentIndexNumber}</a>
|
>Season {item.parentIndexNumber}</a>
|
||||||
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
||||||
@@ -466,6 +502,7 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-3 flex-wrap">
|
<div class="flex gap-3 flex-wrap">
|
||||||
|
{#if canPlay}
|
||||||
<button
|
<button
|
||||||
onclick={handlePlayAll}
|
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"
|
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"
|
||||||
@@ -473,8 +510,9 @@
|
|||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Play
|
{playLabel}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{#if item.kind !== "episode" && item.kind !== "movie"}
|
{#if item.kind !== "episode" && item.kind !== "movie"}
|
||||||
<button
|
<button
|
||||||
onclick={handleShufflePlay}
|
onclick={handleShufflePlay}
|
||||||
@@ -498,6 +536,12 @@
|
|||||||
seriesName={item.name}
|
seriesName={item.name}
|
||||||
episodeCount={allEpisodes.length || undefined}
|
episodeCount={allEpisodes.length || undefined}
|
||||||
/>
|
/>
|
||||||
|
<ClearHistoryButton
|
||||||
|
itemId={item.id}
|
||||||
|
itemName={item.name}
|
||||||
|
scope="series"
|
||||||
|
onCleared={loadItem}
|
||||||
|
/>
|
||||||
{:else if item.kind === "movie"}
|
{:else if item.kind === "movie"}
|
||||||
<VideoDownloadButton
|
<VideoDownloadButton
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
@@ -627,7 +671,11 @@
|
|||||||
{season}
|
{season}
|
||||||
{episodes}
|
{episodes}
|
||||||
focusedEpisodeId={focusedEpisodeId ?? undefined}
|
focusedEpisodeId={focusedEpisodeId ?? undefined}
|
||||||
|
currentEpisodeId={currentEpisode?.id}
|
||||||
|
expanded={expandedSeasons.has(season.id)}
|
||||||
|
onToggle={() => toggleSeason(season.id)}
|
||||||
onEpisodeClick={handleEpisodeClick}
|
onEpisodeClick={handleEpisodeClick}
|
||||||
|
onHistoryCleared={loadItem}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
|
<!--
|
||||||
|
The Movies library — one page, three tabs.
|
||||||
|
|
||||||
|
Was three routes (`/library/movies`, `/library/movies/all`,
|
||||||
|
`/library/movies/genres`). They are now `?view=browse|all|genres` here; the
|
||||||
|
old routes redirect.
|
||||||
|
|
||||||
|
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-105
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { navigateUp } from "$lib/utils/navigation";
|
import { navigateUp } from "$lib/utils/navigation";
|
||||||
import { library, currentLibrary } from "$lib/stores/library";
|
import { library, currentLibrary } from "$lib/stores/library";
|
||||||
@@ -9,32 +18,47 @@
|
|||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||||
|
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
|
||||||
|
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
||||||
|
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
|
||||||
|
import { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
interface Category {
|
const BASE_PATH = "/library/movies";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
description: string;
|
|
||||||
route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories: Category[] = [
|
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
|
||||||
{
|
|
||||||
id: "all",
|
const tabLabels: Record<LibraryView, string> = {
|
||||||
name: "All Movies",
|
browse: "Browse",
|
||||||
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
|
all: "All Movies",
|
||||||
description: "Browse all movies",
|
genres: "Genres",
|
||||||
route: "/library/movies/all",
|
};
|
||||||
},
|
|
||||||
{
|
const allMoviesConfig = {
|
||||||
id: "genres",
|
itemType: "Movie" as const,
|
||||||
name: "Genres",
|
title: "Movies",
|
||||||
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
backPath: BASE_PATH,
|
||||||
description: "Browse by genre",
|
searchPlaceholder: "Search movies...",
|
||||||
route: "/library/movies/genres",
|
sortOptions: [
|
||||||
},
|
{ key: "SortName", label: "A-Z" },
|
||||||
];
|
{ key: "ProductionYear", label: "Year" },
|
||||||
|
{ key: "DateCreated", label: "Recently Added" },
|
||||||
|
{ key: "CommunityRating", label: "Rating" },
|
||||||
|
],
|
||||||
|
defaultSort: "SortName",
|
||||||
|
displayComponent: "grid" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const genresConfig = {
|
||||||
|
itemTypes: ["Movie" as const],
|
||||||
|
title: "Movie Genres",
|
||||||
|
backPath: BASE_PATH,
|
||||||
|
genreIcon:
|
||||||
|
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
||||||
|
itemDisplayMode: "poster" as const,
|
||||||
|
searchPlaceholder: "Search genres...",
|
||||||
|
noItemsMessage: "No movies found in this genre",
|
||||||
|
};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!$currentLibrary) {
|
if (!$currentLibrary) {
|
||||||
@@ -70,19 +94,12 @@
|
|||||||
const genreRows = $derived($movies.genreRows);
|
const genreRows = $derived($movies.genreRows);
|
||||||
const isLoading = $derived($movies.isLoading);
|
const isLoading = $derived($movies.isLoading);
|
||||||
const hasContent = $derived(
|
const hasContent = $derived(
|
||||||
heroItems.length > 0 ||
|
heroItems.length > 0 || continueWatching.length > 0 || recentlyAdded.length > 0
|
||||||
continueWatching.length > 0 ||
|
|
||||||
recentlyAdded.length > 0
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isLoading}
|
<div class="space-y-6 pb-8">
|
||||||
<div class="flex justify-center items-center py-32">
|
<!-- Header — one per page, shared by every tab -->
|
||||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-8 pb-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between px-4">
|
<div class="flex items-center justify-between px-4">
|
||||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
|
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
|
||||||
<button
|
<button
|
||||||
@@ -97,6 +114,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
|
||||||
|
|
||||||
|
{#if view === "all"}
|
||||||
|
<div class="px-4">
|
||||||
|
<GenericMediaListPage config={allMoviesConfig} showHeader={false} />
|
||||||
|
</div>
|
||||||
|
{:else if view === "genres"}
|
||||||
|
<div class="px-4">
|
||||||
|
<GenericGenreBrowser config={genresConfig} showHeader={false} />
|
||||||
|
</div>
|
||||||
|
{:else if isLoading}
|
||||||
|
<div class="flex justify-center items-center py-32">
|
||||||
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-8">
|
||||||
<!-- Hero Banner -->
|
<!-- Hero Banner -->
|
||||||
{#if heroItems.length > 0}
|
{#if heroItems.length > 0}
|
||||||
<HeroBanner items={heroItems} />
|
<HeroBanner items={heroItems} />
|
||||||
@@ -117,7 +150,7 @@
|
|||||||
title="Recently Added"
|
title="Recently Added"
|
||||||
items={recentlyAdded}
|
items={recentlyAdded}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
showAll={() => goto("/library/movies/all")}
|
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -127,35 +160,13 @@
|
|||||||
title={row.name}
|
title={row.name}
|
||||||
items={row.items}
|
items={row.items}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
showAll={() => goto(`/library/movies/genres`)}
|
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if !hasContent}
|
{#if !hasContent}
|
||||||
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
|
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Browse by category -->
|
|
||||||
<div class="space-y-3 px-4 pt-4">
|
|
||||||
<h2 class="text-2xl font-semibold text-white">Browse</h2>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
||||||
{#each categories as category (category.id)}
|
|
||||||
<button
|
|
||||||
onclick={() => goto(category.route)}
|
|
||||||
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
|
|
||||||
>
|
|
||||||
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
||||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d={category.icon} />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
{/if}
|
||||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
</div>
|
||||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Movie browser (all movies)
|
|
||||||
* @req: UR-007 - Navigate media in library
|
|
||||||
* @req: UR-008 - Search media across libraries
|
|
||||||
* @req: DR-007 - Library browsing screens
|
|
||||||
*/
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Movie" as const,
|
|
||||||
title: "Movies",
|
|
||||||
backPath: "/library/movies",
|
|
||||||
searchPlaceholder: "Search movies...",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "A-Z" },
|
|
||||||
{ key: "ProductionYear", label: "Year" },
|
|
||||||
{ key: "DateCreated", label: "Recently Added" },
|
|
||||||
{ key: "CommunityRating", label: "Rating" },
|
|
||||||
],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "grid" as const,
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<GenericMediaListPage {config} />
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Legacy route — now the Movies library's All Movies tab.
|
||||||
|
//
|
||||||
|
// Kept as a redirect rather than deleted: GenreTags builds links to these
|
||||||
|
// paths and users have them in history.
|
||||||
|
//
|
||||||
|
// TRACES: UR-063 | DR-105
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
redirect(307, "/library/movies?view=all");
|
||||||
|
};
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Movie genre browser
|
|
||||||
* @req: UR-007 - Navigate media in library
|
|
||||||
* @req: UR-030 - Quick genre browsing and filtering
|
|
||||||
* @req: DR-007 - Library browsing screens
|
|
||||||
*/
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemTypes: ["Movie" as const],
|
|
||||||
title: "Movie Genres",
|
|
||||||
backPath: "/library",
|
|
||||||
genreIcon:
|
|
||||||
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
|
||||||
itemDisplayMode: "poster" as const,
|
|
||||||
searchPlaceholder: "Search genres...",
|
|
||||||
noItemsMessage: "No movies found in this genre",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<GenericGenreBrowser {config} />
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Legacy route — now the Movies library's Genres tab.
|
||||||
|
//
|
||||||
|
// Kept as a redirect rather than deleted: GenreTags builds links to these
|
||||||
|
// paths and users have them in history.
|
||||||
|
//
|
||||||
|
// TRACES: UR-063 | DR-105
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
redirect(307, "/library/movies?view=genres");
|
||||||
|
};
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TV show genre browser
|
|
||||||
* @req: UR-007 - Navigate media in library
|
|
||||||
* @req: UR-030 - Quick genre browsing and filtering
|
|
||||||
* @req: DR-007 - Library browsing screens
|
|
||||||
*/
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemTypes: ["Series" as const],
|
|
||||||
title: "TV Genres",
|
|
||||||
backPath: "/library",
|
|
||||||
genreIcon:
|
|
||||||
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
|
||||||
itemDisplayMode: "poster" as const,
|
|
||||||
searchPlaceholder: "Search genres...",
|
|
||||||
noItemsMessage: "No shows found in this genre",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<GenericGenreBrowser {config} />
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Legacy route — now the TV library's Genres tab.
|
||||||
|
//
|
||||||
|
// Kept as a redirect rather than deleted: GenreTags builds links to these
|
||||||
|
// paths and users have them in history.
|
||||||
|
//
|
||||||
|
// TRACES: UR-063 | DR-105
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
redirect(307, "/library/tv?view=genres");
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
|
<!--
|
||||||
|
The TV library — one page, three tabs.
|
||||||
|
|
||||||
|
Was three routes (`/library/tv`, `/library/tv/shows`, `/library/shows/genres`,
|
||||||
|
the last of which did not even share a prefix with the others). They are now
|
||||||
|
`?view=browse|all|genres` here; the old routes redirect.
|
||||||
|
|
||||||
|
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-103, DR-105
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { navigateUp } from "$lib/utils/navigation";
|
import { navigateUp } from "$lib/utils/navigation";
|
||||||
import { library, currentLibrary } from "$lib/stores/library";
|
import { library, currentLibrary } from "$lib/stores/library";
|
||||||
@@ -9,32 +18,48 @@
|
|||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||||
|
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
|
||||||
|
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
||||||
|
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
|
||||||
|
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
|
||||||
|
import { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
interface Category {
|
const BASE_PATH = "/library/tv";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
description: string;
|
|
||||||
route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories: Category[] = [
|
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
|
||||||
{
|
|
||||||
id: "shows",
|
const tabLabels: Record<LibraryView, string> = {
|
||||||
name: "All Shows",
|
browse: "Browse",
|
||||||
icon: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z",
|
all: "All Shows",
|
||||||
description: "Browse all series",
|
genres: "Genres",
|
||||||
route: "/library/tv/shows",
|
};
|
||||||
},
|
|
||||||
{
|
const allShowsConfig = {
|
||||||
id: "genres",
|
itemType: "Series" as const,
|
||||||
name: "Genres",
|
title: "TV Shows",
|
||||||
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
backPath: BASE_PATH,
|
||||||
description: "Browse by genre",
|
searchPlaceholder: "Search shows...",
|
||||||
route: "/library/shows/genres",
|
sortOptions: [
|
||||||
},
|
{ key: "SortName", label: "A-Z" },
|
||||||
];
|
{ key: "ProductionYear", label: "Year" },
|
||||||
|
{ key: "DateCreated", label: "Recently Added" },
|
||||||
|
{ key: "CommunityRating", label: "Rating" },
|
||||||
|
],
|
||||||
|
defaultSort: "SortName",
|
||||||
|
displayComponent: "grid" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const genresConfig = {
|
||||||
|
itemTypes: ["Series" as const],
|
||||||
|
title: "TV Genres",
|
||||||
|
backPath: BASE_PATH,
|
||||||
|
genreIcon:
|
||||||
|
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
||||||
|
itemDisplayMode: "poster" as const,
|
||||||
|
searchPlaceholder: "Search genres...",
|
||||||
|
noItemsMessage: "No shows found in this genre",
|
||||||
|
};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!$currentLibrary) {
|
if (!$currentLibrary) {
|
||||||
@@ -57,13 +82,20 @@
|
|||||||
|
|
||||||
function handleItemClick(item: MediaItem) {
|
function handleItemClick(item: MediaItem) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "Series":
|
// A season lands on its series, anchored at that season (DR-103).
|
||||||
case "Season":
|
case "Season":
|
||||||
|
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
|
||||||
|
break;
|
||||||
|
// An episode opens inside its series, never as a bare episode page and
|
||||||
|
// never straight into the player (ux-flows §5B.1, §5B.5).
|
||||||
|
case "Episode":
|
||||||
|
goto(episodeFocusHref(item));
|
||||||
|
break;
|
||||||
|
case "Series":
|
||||||
case "Folder":
|
case "Folder":
|
||||||
goto(`/library/${item.id}`);
|
goto(`/library/${item.id}`);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Episodes and movies play directly.
|
|
||||||
goto(`/player/${item.id}`);
|
goto(`/player/${item.id}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -83,13 +115,8 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isLoading}
|
<div class="space-y-6 pb-8">
|
||||||
<div class="flex justify-center items-center py-32">
|
<!-- Header — one per page, shared by every tab -->
|
||||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-8 pb-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between px-4">
|
<div class="flex items-center justify-between px-4">
|
||||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
||||||
<button
|
<button
|
||||||
@@ -104,6 +131,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
|
||||||
|
|
||||||
|
{#if view === "all"}
|
||||||
|
<div class="px-4">
|
||||||
|
<GenericMediaListPage config={allShowsConfig} showHeader={false} />
|
||||||
|
</div>
|
||||||
|
{:else if view === "genres"}
|
||||||
|
<div class="px-4">
|
||||||
|
<GenericGenreBrowser config={genresConfig} showHeader={false} />
|
||||||
|
</div>
|
||||||
|
{:else if isLoading}
|
||||||
|
<div class="flex justify-center items-center py-32">
|
||||||
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-8">
|
||||||
<!-- Hero Banner -->
|
<!-- Hero Banner -->
|
||||||
{#if heroItems.length > 0}
|
{#if heroItems.length > 0}
|
||||||
<HeroBanner items={heroItems} />
|
<HeroBanner items={heroItems} />
|
||||||
@@ -120,11 +163,7 @@
|
|||||||
|
|
||||||
<!-- Next Up -->
|
<!-- Next Up -->
|
||||||
{#if nextUp.length > 0}
|
{#if nextUp.length > 0}
|
||||||
<Carousel
|
<Carousel title="Next Up" items={nextUp} onItemClick={handleItemClick} />
|
||||||
title="Next Up"
|
|
||||||
items={nextUp}
|
|
||||||
onItemClick={handleItemClick}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Recently Added -->
|
<!-- Recently Added -->
|
||||||
@@ -133,7 +172,7 @@
|
|||||||
title="Recently Added"
|
title="Recently Added"
|
||||||
items={recentlyAdded}
|
items={recentlyAdded}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
showAll={() => goto("/library/tv/shows")}
|
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -143,35 +182,13 @@
|
|||||||
title={row.name}
|
title={row.name}
|
||||||
items={row.items}
|
items={row.items}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
showAll={() => goto(`/library/shows/genres`)}
|
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if !hasContent}
|
{#if !hasContent}
|
||||||
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
|
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Browse by category -->
|
|
||||||
<div class="space-y-3 px-4 pt-4">
|
|
||||||
<h2 class="text-2xl font-semibold text-white">Browse</h2>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
||||||
{#each categories as category (category.id)}
|
|
||||||
<button
|
|
||||||
onclick={() => goto(category.route)}
|
|
||||||
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
|
|
||||||
>
|
|
||||||
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
||||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d={category.icon} />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
{/if}
|
||||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
</div>
|
||||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TV show browser (all series)
|
|
||||||
* @req: UR-007 - Navigate media in library
|
|
||||||
* @req: UR-008 - Search media across libraries
|
|
||||||
* @req: DR-007 - Library browsing screens
|
|
||||||
*/
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Series" as const,
|
|
||||||
title: "TV Shows",
|
|
||||||
backPath: "/library/tv",
|
|
||||||
searchPlaceholder: "Search shows...",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "A-Z" },
|
|
||||||
{ key: "ProductionYear", label: "Year" },
|
|
||||||
{ key: "DateCreated", label: "Recently Added" },
|
|
||||||
{ key: "CommunityRating", label: "Rating" },
|
|
||||||
],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "grid" as const,
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<GenericMediaListPage {config} />
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Legacy route — now the TV library's All Shows tab.
|
||||||
|
//
|
||||||
|
// Kept as a redirect rather than deleted: GenreTags builds links to these
|
||||||
|
// paths and users have them in history.
|
||||||
|
//
|
||||||
|
// TRACES: UR-063 | DR-105
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
redirect(307, "/library/tv?view=all");
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
||||||
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
||||||
|
import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface";
|
||||||
import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
|
import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
|
||||||
import {
|
import {
|
||||||
reportPlaybackStart,
|
reportPlaybackStart,
|
||||||
@@ -72,6 +73,10 @@
|
|||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let loadedItemId: string | null = null;
|
let loadedItemId: string | null = null;
|
||||||
|
|
||||||
|
// Which player component to render. Video without a stream URL is "pending"
|
||||||
|
// (still resolving), never audio — see playerSurface.ts.
|
||||||
|
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl }));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Start position polling (only for audio via MPV backend)
|
// Start position polling (only for audio via MPV backend)
|
||||||
pollInterval = setInterval(updateStatus, 1000);
|
pollInterval = setInterval(updateStatus, 1000);
|
||||||
@@ -137,30 +142,33 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this track is already playing in the backend, just show the UI
|
|
||||||
// without restarting playback (e.g., when expanding from MiniPlayer).
|
|
||||||
// forceRestart bypasses this so advancing to the next episode always
|
|
||||||
// restarts from the beginning even if it were already loaded.
|
|
||||||
const alreadyPlayingMedia = get(storeCurrentMedia);
|
|
||||||
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
|
|
||||||
console.log("loadAndPlay: Track already playing, showing UI without restarting");
|
|
||||||
isLive = item.kind === "liveChannel";
|
|
||||||
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
|
|
||||||
isPlaying = true;
|
|
||||||
loading = false;
|
|
||||||
// hasNext/hasPrevious come from the event-driven queue store.
|
|
||||||
// Fetch next episode for video skip button
|
|
||||||
if (isVideo) {
|
|
||||||
fetchNextEpisode(item);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is video content (Movie, Episode, live TV channels, and
|
// Determine if this is video content (Movie, Episode, live TV channels, and
|
||||||
// channel leaf items that carry a video stream).
|
// channel leaf items that carry a video stream).
|
||||||
isLive = item.kind === "liveChannel";
|
isLive = item.kind === "liveChannel";
|
||||||
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
|
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
|
||||||
|
|
||||||
|
// If this track is already playing in the backend, just show the UI
|
||||||
|
// without restarting playback (e.g., when expanding from MiniPlayer).
|
||||||
|
// Audio only, and forceRestart bypasses it so advancing to the next
|
||||||
|
// episode always restarts from the beginning — see playerSurface.ts for
|
||||||
|
// why video must never take this shortcut.
|
||||||
|
const alreadyPlayingMedia = get(storeCurrentMedia);
|
||||||
|
if (
|
||||||
|
shouldReuseActivePlayback({
|
||||||
|
requestedId: id,
|
||||||
|
activeMediaId: alreadyPlayingMedia?.id,
|
||||||
|
isVideo,
|
||||||
|
startPosition,
|
||||||
|
forceRestart,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
console.log("loadAndPlay: Track already playing, showing UI without restarting");
|
||||||
|
isPlaying = true;
|
||||||
|
loading = false;
|
||||||
|
// hasNext/hasPrevious come from the event-driven queue store.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// When switching to video, stop audio playback and clear the queue
|
// When switching to video, stop audio playback and clear the queue
|
||||||
// This prevents audio from continuing in the background and clears stale state
|
// This prevents audio from continuing in the background and clears stale state
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
@@ -650,10 +658,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if loading}
|
|
||||||
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
|
|
||||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
|
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
|
||||||
<div class="text-center max-w-lg">
|
<div class="text-center max-w-lg">
|
||||||
@@ -667,7 +671,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if isVideo && streamUrl}
|
{:else if loading || surface === "pending"}
|
||||||
|
<!-- "pending" = video whose stream URL has not resolved yet. Showing the
|
||||||
|
spinner keeps it out of the audio player. -->
|
||||||
|
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
|
||||||
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
{:else if surface === "video" && streamUrl}
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
media={currentMedia}
|
media={currentMedia}
|
||||||
{streamUrl}
|
{streamUrl}
|
||||||
|
|||||||
Reference in New Issue
Block a user