Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f2506966 | ||
|
|
a818fee297 | ||
|
|
a26a853f01 |
@@ -72,6 +72,9 @@ For a narrative overview of the system design, see
|
||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -253,6 +256,14 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -323,6 +334,9 @@ Internal architecture, components, and application logic.
|
||||
| UR-058 | - | DR-087 |
|
||||
| UR-060 | - | DR-090, DR-091 |
|
||||
| UR-061 | - | DR-092 |
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
|
||||
---
|
||||
|
||||
@@ -420,6 +434,8 @@ Internal architecture, components, and application logic.
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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).
|
||||
+1233
-901
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
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
Hero (poster, title, metadata, Resume SxEy / Download / Clear history)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Seasons (collapsible; only the current season expanded)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
@@ -700,6 +700,29 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||
The reason a user opens a series page is to pick an episode; discovery content
|
||||
is secondary and sits underneath.
|
||||
|
||||
**Rules for the seasons block** *(UR-062, UR-064)*:
|
||||
|
||||
- **The page opens where the viewer is.** The backend resolves the current
|
||||
episode — in progress, else Next Up, else first unwatched, else the premiere —
|
||||
and the page scrolls it into view with an `Up next` badge and a highlight ring.
|
||||
Never season 1 by default, unless season 1 *is* where the viewer is.
|
||||
- **Seasons collapse; only the current one is expanded.** A ten-season show
|
||||
otherwise renders hundreds of rows and buries the episode the viewer came for.
|
||||
A collapsed season still names its episode count and watched count, so
|
||||
progress is readable without expanding it.
|
||||
- **The hero button opens, it does not play.** It reads `Resume S2E4` /
|
||||
`Play S1E1` — naming its target — and navigates to that episode's Focus View,
|
||||
where Play commits. Play on a *container* is navigation (§5B.5); Play on a
|
||||
*leaf* is the commitment.
|
||||
- **A season is never its own page.** `/library/<seasonId>` redirects to
|
||||
`/library/<seriesId>#season-N`. Every affordance that names a season — the
|
||||
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
|
||||
the series with that season in view, so the episodes of all seasons stay one
|
||||
browsable list.
|
||||
- **Watch history is erasable** per series (hero) and per season (season
|
||||
header). It confirms first, cannot be undone, and needs the server. Clearing a
|
||||
whole series returns it to S1E1 by the same path a never-watched show takes.
|
||||
|
||||
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||
|
||||
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.2.8",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
||||
);
|
||||
const defined = countDefinedRequirements(md);
|
||||
|
||||
expect(defined.UR).toBe(61);
|
||||
expect(defined.UR).toBe(64);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(96);
|
||||
expect(defined.DR).toBe(104);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(218);
|
||||
expect(defined.total).toBe(229);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.2.8"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.2.8"
|
||||
version = "0.3.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
||||
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
||||
|
||||
/// Base offset (seconds) for the active background-audio handoff.
|
||||
///
|
||||
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
|
||||
/// position, so the server makes that point the stream's zero. ExoPlayer then
|
||||
/// reports position RELATIVE to that zero. To convert back to an absolute
|
||||
/// position on exit (so the video resumes where the audio actually reached), we
|
||||
/// add this stored base to the native player's reported position.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-052
|
||||
#[derive(Default)]
|
||||
pub struct BackgroundAudioOffset(pub Mutex<f64>);
|
||||
|
||||
/// Response for player state queries
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -586,7 +574,6 @@ pub async fn player_play_item(
|
||||
pub async fn player_enter_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
session: State<'_, MediaSessionManagerWrapper>,
|
||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||
item: PlayItemRequest,
|
||||
position_seconds: f64,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
@@ -636,17 +623,18 @@ pub async fn player_enter_background_audio(
|
||||
session_mgr.start_audio_session(media_item.clone());
|
||||
}
|
||||
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// this base to the native player's relative position to get the absolute one.
|
||||
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
|
||||
|
||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// this base to the native player's relative position to get the absolute one.
|
||||
// The controller owns it so a backend-driven advance to the next episode
|
||||
// clears it along with the stream it described.
|
||||
controller.set_background_audio_base(position_seconds);
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -677,21 +665,15 @@ pub async fn player_enter_background_audio(
|
||||
#[specta::specta]
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||
) -> Result<f64, String> {
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Read/reset the base first.
|
||||
let base = {
|
||||
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
|
||||
let b = *off;
|
||||
*off = 0.0;
|
||||
b
|
||||
};
|
||||
|
||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Zero after a backend-driven
|
||||
// episode advance, whose stream already starts at its own zero.
|
||||
let base = controller.take_background_audio_base();
|
||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||
let relative = controller.position();
|
||||
|
||||
@@ -141,6 +141,8 @@ pub async fn player_play_next_episode(
|
||||
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
/// - Android JNI callback also triggers this logic directly
|
||||
///
|
||||
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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 {
|
||||
controller_arc
|
||||
.lock()
|
||||
.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::jellyfin::HttpClient;
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||
OnlineRepository,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
@@ -320,6 +321,71 @@ pub async fn repository_get_next_up_episodes(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Every episode of a series, across all seasons, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and its fallback live in Rust rather than being reimplemented in the
|
||||
/// frontend (which is what it used to do).
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_episodes(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open a series.
|
||||
///
|
||||
/// "Current" is domain policy, not layout: an episode in progress, else the
|
||||
/// server's Next Up for the series, else the first unwatched episode, else the
|
||||
/// first. The third rung is what makes this work offline, where Next Up is
|
||||
/// always empty. Returns `None` only when the series has no episodes at all.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_current_episode(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Option<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Erase the viewer's watch history for an item.
|
||||
///
|
||||
/// Clears the played flag and the resume position; on a series or season the
|
||||
/// server applies it to everything inside. A series cleared this way is "never
|
||||
/// watched" again, so `repository_get_series_current_episode` returns its
|
||||
/// premiere. Requires the server — offline this fails rather than diverging
|
||||
/// local state the next sync would overwrite.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_clear_watch_history(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.clear_watch_history(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get recently played audio
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -178,6 +178,7 @@ use commands::{
|
||||
remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// Repository commands
|
||||
repository_clear_watch_history,
|
||||
repository_create,
|
||||
repository_destroy,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
@@ -201,6 +202,8 @@ use commands::{
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_resume_items,
|
||||
repository_get_resume_movies,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_similar_items,
|
||||
repository_get_subtitle_url,
|
||||
repository_get_video_download_url,
|
||||
@@ -869,6 +872,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_current_episode,
|
||||
repository_clear_watch_history,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
@@ -1196,9 +1202,6 @@ pub fn run() {
|
||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||
app.manage(video_settings);
|
||||
|
||||
// Background-audio handoff base offset (UR-040).
|
||||
app.manage(commands::player::BackgroundAudioOffset::default());
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
|
||||
@@ -915,39 +915,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Background audio-only episode: the frontend that normally
|
||||
// performs the advance (goto /player/<id>) is suspended, so
|
||||
// the backend must load the next episode's audio-only stream
|
||||
// itself — otherwise playback just stops at the boundary.
|
||||
let is_bg_audio_episode =
|
||||
controller.lock().await.current_is_audio_episode();
|
||||
if is_bg_audio_episode {
|
||||
log::info!(
|
||||
"[Autoplay] Background audio episode — advancing to {} in backend",
|
||||
next_episode.id
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl
|
||||
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"[Autoplay] Background audio advance failed: {} — stopping",
|
||||
e
|
||||
);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else {
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
} else {
|
||||
// Foreground: frontend drives the advance off the countdown.
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
}
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+205
-4
@@ -105,7 +105,7 @@ pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String
|
||||
}
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, warn};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
@@ -153,6 +153,21 @@ pub struct PlayerController {
|
||||
// Auto-play episode counter (session-based, resets on manual play)
|
||||
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
|
||||
@@ -185,6 +200,7 @@ impl PlayerController {
|
||||
position_throttler,
|
||||
end_reason: Arc::new(Mutex::new(None)),
|
||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||
background_audio_base: Arc::new(Mutex::new(0.0)),
|
||||
html5_playing: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
@@ -1170,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.
|
||||
///
|
||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||
@@ -1180,10 +1258,10 @@ impl PlayerController {
|
||||
///
|
||||
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
||||
///
|
||||
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
|
||||
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
/// Reached through `auto_advance_to_next_episode`, which gates it on
|
||||
/// `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
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub async fn advance_to_next_episode_audio_only(
|
||||
&self,
|
||||
next_episode_id: &str,
|
||||
@@ -1238,6 +1316,13 @@ impl PlayerController {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -2777,6 +2862,9 @@ mod tests {
|
||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
@@ -2994,6 +3082,119 @@ mod tests {
|
||||
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
|
||||
/// stop gracefully (previous behavior) rather than error.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -750,6 +750,11 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.unmark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.clear_watch_history(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -1128,6 +1133,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1386,6 +1395,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod series_progress;
|
||||
pub mod types;
|
||||
|
||||
pub use hybrid::HybridRepository;
|
||||
@@ -211,6 +212,14 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// Unmark item as favorite
|
||||
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
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
|
||||
@@ -1627,6 +1627,12 @@ impl MediaRepository for OfflineRepository {
|
||||
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> {
|
||||
let query = Query::with_params(
|
||||
"SELECT id, name, overview, primary_image_tag
|
||||
|
||||
@@ -1691,6 +1691,48 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
|
||||
/// unplayed", which also zeroes the resume position. On a folder (series,
|
||||
/// season) the server applies it recursively to the children.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106, JA-033
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
self.report_outcome(&result).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Where a viewer is in a TV series.
|
||||
//!
|
||||
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
|
||||
//! means ("in progress", "played") and what Jellyfin's season numbering means
|
||||
//! (season 0 is specials). The frontend asks for *the* current episode and
|
||||
//! renders it; it does not get to decide what "current" means.
|
||||
//!
|
||||
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
|
||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||
//! can be unit-tested without standing up a repository.
|
||||
//!
|
||||
//! TRACES: UR-062 | DR-101
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
/// Jellyfin files specials under season 0.
|
||||
const SPECIALS_SEASON: i32 = 0;
|
||||
|
||||
/// Below this fraction watched, a position is a false start rather than
|
||||
/// progress — the same threshold the resume dialog uses.
|
||||
const MIN_PROGRESS_FRACTION: f64 = 0.01;
|
||||
|
||||
/// Above this fraction watched, an episode is effectively finished; resuming it
|
||||
/// would drop the viewer into the closing credits.
|
||||
const MAX_PROGRESS_FRACTION: f64 = 0.95;
|
||||
|
||||
/// Sort key for a season number. Specials sort *after* every numbered season:
|
||||
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
|
||||
/// not lead just because `0 < 1`.
|
||||
fn season_rank(season: Option<i32>) -> i64 {
|
||||
match season {
|
||||
Some(SPECIALS_SEASON) => i64::MAX,
|
||||
Some(n) => n as i64,
|
||||
None => i64::MAX - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Order episodes as the series is watched: season ascending, then episode,
|
||||
/// specials last.
|
||||
pub fn sort_series_order(episodes: &mut [MediaItem]) {
|
||||
episodes.sort_by(|a, b| {
|
||||
season_rank(a.parent_index_number)
|
||||
.cmp(&season_rank(b.parent_index_number))
|
||||
.then(
|
||||
a.index_number
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.index_number.unwrap_or(0)),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Is this episode genuinely part-watched (not a false start, not finished)?
|
||||
fn is_in_progress(item: &MediaItem) -> bool {
|
||||
let Some(user_data) = item.user_data.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if user_data.is_played.unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let position_ms = user_data
|
||||
.playback_position_ms
|
||||
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||
.unwrap_or(0);
|
||||
if position_ms <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Without a duration we cannot tell "2 minutes in" from "2 minutes left",
|
||||
// so any recorded position counts as progress.
|
||||
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let fraction = position_ms as f64 / duration_ms as f64;
|
||||
(MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
|
||||
}
|
||||
|
||||
fn is_played(item: &MediaItem) -> bool {
|
||||
item.user_data
|
||||
.as_ref()
|
||||
.and_then(|u| u.is_played)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
item.series_id.as_deref() == Some(series_id)
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open `series_id`.
|
||||
///
|
||||
/// Order of preference, and why:
|
||||
///
|
||||
/// 1. **An episode in progress.** That is literally where playback stopped;
|
||||
/// Next Up would skip past it. On a tie the earliest in series order wins, so
|
||||
/// a viewer who dipped into a later episode still returns to the one they are
|
||||
/// working through.
|
||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||
/// we do not cache locally.
|
||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
||||
/// this rung the whole feature would be online-only.
|
||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||
/// rather than on nothing.
|
||||
///
|
||||
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
|
||||
/// (the season fan-out can miss an id the server returns), but only when they
|
||||
/// belong to this series.
|
||||
pub fn pick_current_episode(
|
||||
series_id: &str,
|
||||
episodes: &[MediaItem],
|
||||
next_up: &[MediaItem],
|
||||
resume: &[MediaItem],
|
||||
) -> Option<MediaItem> {
|
||||
// 1. In progress — prefer a match inside the ordered episode list so the
|
||||
// "earliest in series order" tie-break is meaningful; fall back to the
|
||||
// resume feed for an episode the fan-out missed.
|
||||
if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
if let Some(found) = resume
|
||||
.iter()
|
||||
.find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
|
||||
{
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 2. Next Up for this series.
|
||||
if let Some(found) = next_up
|
||||
.iter()
|
||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
||||
{
|
||||
// Prefer the copy from `episodes` when we have one: it carries the
|
||||
// user-data and images the list already fetched.
|
||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||
return Some(matched.unwrap_or(found).clone());
|
||||
}
|
||||
|
||||
// 3. First unwatched in series order.
|
||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 4. First episode — a fully-watched series reopens at the start.
|
||||
episodes.first().cloned()
|
||||
}
|
||||
|
||||
/// Every episode of a series, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders, except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and the fallback live here rather than in the frontend.
|
||||
pub async fn fetch_series_episodes(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let children = repo.get_items(series_id, list_options()).await?;
|
||||
|
||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
||||
// One failing season must not blank the whole show.
|
||||
match repo.get_items(&season.id, list_options()).await {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[series] season {} of {} failed to load: {:?}",
|
||||
season.id,
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flat series: the children *are* the episodes.
|
||||
if episodes.is_empty() {
|
||||
episodes.extend(children.items.into_iter().filter(is_episode));
|
||||
}
|
||||
|
||||
sort_series_order(&mut episodes);
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
/// `pick_current_episode` has fallbacks for exactly that.
|
||||
pub async fn resolve_current_episode(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Option<MediaItem>, RepoError> {
|
||||
let episodes = fetch_series_episodes(repo, series_id).await?;
|
||||
|
||||
let next_up = repo
|
||||
.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resume = repo
|
||||
.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(pick_current_episode(
|
||||
series_id, &episodes, &next_up, &resume,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_options() -> Option<GetItemsOptions> {
|
||||
Some(GetItemsOptions {
|
||||
limit: Some(500),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_season(item: &MediaItem) -> bool {
|
||||
item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
|
||||
}
|
||||
|
||||
fn is_episode(item: &MediaItem) -> bool {
|
||||
item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::UserData;
|
||||
|
||||
const SERIES: &str = "series-1";
|
||||
|
||||
fn episode(id: &str, season: i32, number: i32) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("S{season}E{number}"),
|
||||
item_type: "Episode".to_string(),
|
||||
series_id: Some(SERIES.to_string()),
|
||||
parent_index_number: Some(season),
|
||||
index_number: Some(number),
|
||||
duration_ms: Some(1_000_000),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
|
||||
let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some((duration * fraction) as i64),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn season(n: i32, count: i32) -> Vec<MediaItem> {
|
||||
(1..=count)
|
||||
.map(|i| episode(&format!("s{n}e{i}"), n, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_season_then_episode() {
|
||||
let mut eps = vec![
|
||||
episode("b", 2, 1),
|
||||
episode("d", 1, 10),
|
||||
episode("a", 1, 2),
|
||||
episode("c", 2, 2),
|
||||
];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "d", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_specials_after_numbered_seasons() {
|
||||
let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["premiere", "special"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_in_progress_episode_over_next_up() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.4);
|
||||
// The server would send us past it; the half-watched episode wins.
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_earliest_in_progress_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[1] = in_progress(eps[1].clone(), 0.3);
|
||||
eps[3] = in_progress(eps[3].clone(), 0.5);
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_a_false_start_and_a_finished_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
|
||||
eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
|
||||
|
||||
// Neither counts as progress, so Next Up decides.
|
||||
let next_up = vec![episode("s1e4", 1, 4)];
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_next_up_when_nothing_is_in_progress() {
|
||||
let eps = season(1, 5);
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_up_from_another_series_is_ignored() {
|
||||
let eps = season(1, 3);
|
||||
let mut foreign = episode("other-show-ep", 1, 1);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
/// The offline path: `OfflineRepository::get_next_up_episodes` returns an
|
||||
/// empty vec, so the first unwatched episode has to carry the feature.
|
||||
#[test]
|
||||
fn falls_back_to_first_unwatched_when_next_up_is_empty() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(4) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(3) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_watched_series_opens_on_its_premiere() {
|
||||
let eps = [season(2, 3), season(1, 3)].concat();
|
||||
let mut ordered = eps.clone();
|
||||
sort_series_order(&mut ordered);
|
||||
|
||||
let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_watched_series_reopens_at_the_start() {
|
||||
let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_a_resume_entry_missing_from_the_episode_list() {
|
||||
// Season fan-out returned nothing usable, but the resume feed knows.
|
||||
let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
|
||||
assert_eq!(current.id, "s3e7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_entries_from_other_series_are_ignored() {
|
||||
let mut foreign = in_progress(episode("other", 1, 1), 0.5);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_with_no_episodes_has_no_current_episode() {
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_episode_without_a_duration_still_counts_as_in_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.duration_ms = None;
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some(120_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tick_positions_still_register_as_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
// 400_000 ms expressed in Jellyfin ticks, no ms field.
|
||||
playback_position_ticks: Some(400_000 * 10_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub struct Library {
|
||||
}
|
||||
|
||||
/// User-specific data for an item (playback state, favorites, etc.)
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.2.8",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"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 audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
* - Android JNI callback also triggers this logic directly
|
||||
*
|
||||
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
|
||||
*/
|
||||
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
|
||||
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[]> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -137,6 +137,37 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every episode of a series, across all seasons, already in series order.
|
||||
* The backend owns the season fan-out and the flat-series fallback.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101
|
||||
*/
|
||||
async getSeriesEpisodes(seriesId: string): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode the viewer should land on when opening this series. `null` only
|
||||
* when the series has no episodes.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101
|
||||
*/
|
||||
async getSeriesCurrentEpisode(seriesId: string): Promise<MediaItem | null> {
|
||||
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase watch history for an item. On a series or season the server applies
|
||||
* it to everything inside, so the container returns to "never watched".
|
||||
* Requires the server — this fails offline rather than diverging local state.
|
||||
*
|
||||
* TRACES: UR-064 | DR-106
|
||||
*/
|
||||
async clearWatchHistory(itemId: string): Promise<void> {
|
||||
await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Library, MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
|
||||
import { formatBytes } from "$lib/utils/formatBytes";
|
||||
import {
|
||||
downloadedCatalog,
|
||||
@@ -62,6 +63,16 @@
|
||||
void openLibrary(item as Library);
|
||||
return;
|
||||
}
|
||||
// Seasons and episodes resolve inside their series (DR-103): a season has
|
||||
// no page of its own and an episode is never browsed bare.
|
||||
if (item.kind === "season") {
|
||||
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "episode") {
|
||||
goto(episodeFocusHref(item));
|
||||
return;
|
||||
}
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 type { MediaItem } from "$lib/api/types";
|
||||
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 {
|
||||
episode: MediaItem;
|
||||
@@ -245,8 +249,8 @@
|
||||
<!-- Episode info -->
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
|
||||
{ep.indexNumber || 0}.
|
||||
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
|
||||
{stripCardLabel(ep, episode)}
|
||||
</span>
|
||||
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
||||
{ep.name}
|
||||
|
||||
@@ -10,15 +10,21 @@
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
focused?: boolean;
|
||||
/**
|
||||
* This is the episode the viewer is up to. Marked and scrolled to when the
|
||||
* series page opens, so a viewer four seasons deep lands on their place
|
||||
* instead of the top of season 1. TRACES: UR-062 | DR-102
|
||||
*/
|
||||
current?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { episode, focused = false, onclick }: Props = $props();
|
||||
let { episode, focused = false, current = false, onclick }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (focused && buttonRef) {
|
||||
if ((focused || current) && buttonRef) {
|
||||
// Scroll into view with some offset from top
|
||||
setTimeout(() => {
|
||||
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -51,7 +57,11 @@
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
type="button"
|
||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
|
||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
||||
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: current
|
||||
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
|
||||
: ''}"
|
||||
{onclick}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
@@ -137,6 +147,13 @@
|
||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||
{truncateMiddle(episode.name, 56)}
|
||||
</h3>
|
||||
{#if current}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
>
|
||||
Up next
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Played indicator -->
|
||||
{#if episode.userData?.isPlayed}
|
||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -34,9 +34,16 @@
|
||||
|
||||
interface Props {
|
||||
config: GenreConfig;
|
||||
/**
|
||||
* Suppress the back button + title when this renders as a *tab* of a
|
||||
* library page that already has a header. Drilling into a single genre
|
||||
* still shows the header — there the back button is the way out.
|
||||
* TRACES: UR-063 | DR-105
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
let { config, showHeader = true }: Props = $props();
|
||||
|
||||
let genres = $state<Genre[]>([]);
|
||||
let filteredGenres = $state<Genre[]>([]);
|
||||
@@ -153,7 +160,9 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">
|
||||
@@ -164,6 +173,7 @@
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !selectedGenre}
|
||||
<!-- Genre Browser -->
|
||||
|
||||
@@ -41,9 +41,15 @@
|
||||
|
||||
interface Props {
|
||||
config: MediaListConfig;
|
||||
/**
|
||||
* Suppress the back button + title. Set when this renders as a *tab* of a
|
||||
* library page, which already has its own header — two stacked headers and
|
||||
* two back buttons read as two pages. TRACES: UR-063 | DR-105
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
let { config, showHeader = true }: Props = $props();
|
||||
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -246,10 +252,12 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
{#if showHeader}
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search and Sort Bar -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import type { MediaKind } from "$lib/api/types";
|
||||
import { libraryViewUrl } from "$lib/utils/libraryView";
|
||||
|
||||
interface Props {
|
||||
genres: string[];
|
||||
@@ -17,7 +18,9 @@
|
||||
itemKind
|
||||
}: Props = $props();
|
||||
|
||||
// Map the item kind to its genre-browse route
|
||||
// Map the item kind to its genre-browse surface. Video genres are a tab of
|
||||
// the library page now, not a route of their own (DR-105); linking straight
|
||||
// to the tab avoids a redirect hop through the legacy paths.
|
||||
function genreBasePath(kind: MediaKind | undefined): string {
|
||||
switch (kind) {
|
||||
case "album":
|
||||
@@ -28,11 +31,11 @@
|
||||
case "series":
|
||||
case "season":
|
||||
case "episode":
|
||||
return "/library/shows/genres";
|
||||
return libraryViewUrl("/library/tv", "genres");
|
||||
case "movie":
|
||||
return "/library/movies/genres";
|
||||
return libraryViewUrl("/library/movies", "genres");
|
||||
default:
|
||||
return "/library/movies/genres";
|
||||
return libraryViewUrl("/library/movies", "genres");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
Browse / All / Genres for a video library.
|
||||
|
||||
These were three routes per library with names that did not agree across the
|
||||
two libraries; they are now tabs on one route, driven by `?view=` so a tab is
|
||||
linkable and survives a back navigation.
|
||||
|
||||
TRACES: UR-063 | DR-105
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { LIBRARY_VIEWS, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
|
||||
|
||||
interface Props {
|
||||
/** Route the tabs live on, e.g. `/library/tv`. */
|
||||
basePath: string;
|
||||
active: LibraryView;
|
||||
/** Per-view labels — "All Shows" vs "All Movies". */
|
||||
labels: Record<LibraryView, string>;
|
||||
}
|
||||
|
||||
let { basePath, active, labels }: Props = $props();
|
||||
|
||||
function select(view: LibraryView) {
|
||||
if (view === active) return;
|
||||
// replaceState: switching tabs is not a navigation step worth a back press.
|
||||
goto(libraryViewUrl(basePath, view), { replaceState: true, noScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="flex items-center gap-1 px-4" aria-label="Library sections">
|
||||
{#each LIBRARY_VIEWS as view (view)}
|
||||
<button
|
||||
onclick={() => select(view)}
|
||||
aria-current={view === active ? "page" : undefined}
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{view === active
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
||||
>
|
||||
{labels[view]}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -1,26 +1,56 @@
|
||||
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { seasonAnchorId } from "./seriesNavigation";
|
||||
|
||||
interface Props {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
focusedEpisodeId?: string;
|
||||
/** The episode the viewer is up to — highlighted and scrolled into view. */
|
||||
currentEpisodeId?: string;
|
||||
/**
|
||||
* Whether this season's episode list is open. Only the current season
|
||||
* starts expanded, so a ten-season show does not render every episode at
|
||||
* once. TRACES: UR-062 | DR-107
|
||||
*/
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
onEpisodeClick?: (episode: MediaItem) => void;
|
||||
onHistoryCleared?: () => void;
|
||||
}
|
||||
|
||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||
let {
|
||||
season,
|
||||
episodes,
|
||||
focusedEpisodeId,
|
||||
currentEpisodeId,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
onEpisodeClick,
|
||||
onHistoryCleared,
|
||||
}: Props = $props();
|
||||
|
||||
const holdsCurrentEpisode = $derived(
|
||||
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
|
||||
);
|
||||
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
|
||||
|
||||
const episodeCount = $derived(episodes.length);
|
||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
);
|
||||
// Seasons have no page of their own; a season link scrolls to this anchor
|
||||
// inside the series' single continuous episode list.
|
||||
const anchor = $derived(seasonAnchorId(seasonNumber));
|
||||
</script>
|
||||
|
||||
<section class="space-y-4">
|
||||
<section class="space-y-4 scroll-mt-4" id={anchor}>
|
||||
<!-- Season header -->
|
||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||
<!-- Season poster -->
|
||||
@@ -38,28 +68,60 @@
|
||||
<!-- Season info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{seasonName}
|
||||
<!-- The whole title block toggles the season open/closed. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="{anchor}-episodes"
|
||||
class="flex-1 min-w-0 text-left group/season"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
||||
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="truncate">{seasonName}</span>
|
||||
{#if holdsCurrentEpisode}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
>
|
||||
Up next
|
||||
</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
|
||||
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
|
||||
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
|
||||
<!-- Collapsed, this is the only progress signal the season shows. -->
|
||||
{#if watchedCount > 0}
|
||||
<span>•</span>
|
||||
<span>
|
||||
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
|
||||
</span>
|
||||
{/if}
|
||||
{#if season.productionYear}
|
||||
<span>•</span>
|
||||
<span>{season.productionYear}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if season.overview}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
|
||||
{#if season.overview && expanded}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
|
||||
{season.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Download Season Button -->
|
||||
<div class="flex-shrink-0">
|
||||
<!-- Per-season actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
@@ -68,19 +130,29 @@
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={season.id}
|
||||
itemName={seasonName}
|
||||
scope="season"
|
||||
size="sm"
|
||||
onCleared={onHistoryCleared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode list -->
|
||||
<div class="space-y-1 pl-2">
|
||||
{#if expanded}
|
||||
<div class="space-y-1 pl-2" id="{anchor}-episodes">
|
||||
{#each episodes as episode (episode.id)}
|
||||
<EpisodeRow
|
||||
{episode}
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
current={episode.id === currentEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
|
||||
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
|
||||
|
||||
// Minimal episode factory — only the fields the strip logic reads.
|
||||
function ep(
|
||||
@@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => {
|
||||
expect(strip).toContain(current);
|
||||
});
|
||||
|
||||
it("restricts to the current season when multiple seasons are present", () => {
|
||||
// ux-flows §5B.2, "Cross-season continuity": the window spans the whole
|
||||
// series in episode order, so it runs past a season boundary rather than
|
||||
// dead-ending at the end of a season.
|
||||
it("runs past the end of a season into the next one", () => {
|
||||
const eps = [...season(1, 5), ...season(2, 5)];
|
||||
const current = eps[6]; // S2E2
|
||||
const current = eps[4]; // S1E5 — the season finale
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
|
||||
expect(strip.map((e) => e.id)).toEqual([
|
||||
"s1e2", "s1e3", "s1e4", "s1e5",
|
||||
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reaches back into the previous season from a season opener", () => {
|
||||
const eps = [...season(1, 5), ...season(2, 5)];
|
||||
const current = eps[5]; // S2E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]);
|
||||
expect(strip[3].id).toBe("s2e1");
|
||||
});
|
||||
|
||||
it("orders by season then episode, never interleaving seasons", () => {
|
||||
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
|
||||
const current = eps[3]; // S1E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.map((e) => e.id)).toEqual([
|
||||
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts specials (season 0) after the numbered seasons", () => {
|
||||
const eps = [...season(0, 2), ...season(1, 2)];
|
||||
const current = eps[2]; // S1E1
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]);
|
||||
});
|
||||
|
||||
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
|
||||
@@ -93,5 +123,42 @@ describe("adjacentEpisodes", () => {
|
||||
const current = ep("mystery", null, 3); // no season number
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.length).toBeGreaterThan(1);
|
||||
// Anchored at its episode number, not dumped at one end of the list.
|
||||
expect(strip.indexOf(current)).toBeGreaterThan(0);
|
||||
expect(strip.indexOf(current)).toBeLessThan(strip.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareSeriesOrder", () => {
|
||||
it("orders by season, then episode", () => {
|
||||
expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0);
|
||||
expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0);
|
||||
expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0);
|
||||
});
|
||||
|
||||
it("puts specials last", () => {
|
||||
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0);
|
||||
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to episode number when a season is unknown", () => {
|
||||
expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripCardLabel", () => {
|
||||
const current = ep("cur", 2, 4);
|
||||
|
||||
it("shows a bare episode number within the current season", () => {
|
||||
expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6.");
|
||||
});
|
||||
|
||||
it("shows SxEy once the card crosses a season boundary", () => {
|
||||
expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1");
|
||||
expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8");
|
||||
});
|
||||
|
||||
it("degrades to the episode number when the season is unknown", () => {
|
||||
expect(stripCardLabel(ep("a", null, 7), current)).toBe("7.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
|
||||
//
|
||||
// Extracted from the component so it can be unit-tested: the strip must never
|
||||
// collapse to just the current episode while real siblings exist, and it must
|
||||
// not mistake number-less episodes for the current one.
|
||||
// collapse to just the current episode while real siblings exist, must not
|
||||
// mistake number-less episodes for the current one, and must run past a season
|
||||
// boundary rather than dead-ending at the end of a season (ux-flows §5B.2).
|
||||
//
|
||||
// TRACES: UR-048 | DR-062
|
||||
// TRACES: UR-048, UR-062 | DR-062, DR-104
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/** Episodes shown before / after the current one in the strip window. */
|
||||
const BEFORE = 3;
|
||||
const AFTER = 6;
|
||||
|
||||
/** Jellyfin puts specials in season 0; they air outside the numbered run. */
|
||||
const SPECIALS_SEASON = 0;
|
||||
|
||||
/**
|
||||
* Does `ep` refer to the same episode as `current`?
|
||||
*
|
||||
@@ -29,33 +37,74 @@ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key for a season: specials (season 0) come *after* every numbered
|
||||
* season, matching how a viewer works through a show — S1, S2, …, then the
|
||||
* extras — rather than opening on a special because 0 < 1.
|
||||
*/
|
||||
function seasonRank(seasonNumber: number | null | undefined): number {
|
||||
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast order across a whole series: season ascending, then episode.
|
||||
*
|
||||
* When *either* side's season is unknown there is no season axis to compare on,
|
||||
* so it falls through to episode number. That makes the comparator technically
|
||||
* non-transitive across such a mix, which is safe here because only the
|
||||
* directly-fetched `current` episode can lack a season and it is never part of
|
||||
* the array being sorted — it is only positioned against it (see
|
||||
* `adjacentEpisodes`).
|
||||
*/
|
||||
export function compareSeriesOrder(a: MediaItem, b: MediaItem): number {
|
||||
if (a.parentIndexNumber != null && b.parentIndexNumber != null) {
|
||||
const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber);
|
||||
if (bySeason !== 0) return bySeason;
|
||||
}
|
||||
return (a.indexNumber ?? 0) - (b.indexNumber ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window of episodes shown under the hero: up to 3 before and 6 after the
|
||||
* current episode. Degrades gracefully:
|
||||
* - prefers the current season, falling back to the full list when the season
|
||||
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
|
||||
* - splices the current episode into the pool at its numeric position when it
|
||||
* isn't present, so it still anchors the window;
|
||||
* current episode, in series order across *all* seasons.
|
||||
*
|
||||
* Crossing a season boundary is the point (ux-flows §5B.2): finishing a season
|
||||
* finale should offer the next season's premiere, not an empty strip. Degrades
|
||||
* gracefully:
|
||||
* - splices the current episode into the pool at its ordered position when it
|
||||
* isn't present (an API id mismatch on a directly-fetched episode), so it
|
||||
* still anchors the window;
|
||||
* - returns just `[current]` only when there genuinely are no other episodes.
|
||||
*/
|
||||
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
|
||||
const seasonMatches = allEpisodes.filter(
|
||||
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
|
||||
);
|
||||
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
|
||||
.slice()
|
||||
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
|
||||
const pool = allEpisodes.slice().sort(compareSeriesOrder);
|
||||
|
||||
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
|
||||
|
||||
if (idx === -1) {
|
||||
const epNum = current.indexNumber ?? 0;
|
||||
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
|
||||
const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0);
|
||||
idx = insertAt === -1 ? pool.length : insertAt;
|
||||
pool.splice(idx, 0, current);
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - 3);
|
||||
const end = Math.min(pool.length, idx + 7);
|
||||
const start = Math.max(0, idx - BEFORE);
|
||||
const end = Math.min(pool.length, idx + AFTER + 1);
|
||||
return pool.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a strip card, relative to the episode in focus.
|
||||
*
|
||||
* Within the current season a bare number reads cleanly ("6."). Once the window
|
||||
* crosses into another season that number is ambiguous, so the card names the
|
||||
* season too ("S3E1") — otherwise the premiere after a finale just reads "1."
|
||||
*/
|
||||
export function stripCardLabel(ep: MediaItem, current: MediaItem): string {
|
||||
const crossesSeason =
|
||||
ep.parentIndexNumber != null &&
|
||||
current.parentIndexNumber != null &&
|
||||
ep.parentIndexNumber !== current.parentIndexNumber;
|
||||
|
||||
if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`;
|
||||
return `${ep.indexNumber ?? 0}.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import {
|
||||
seasonAnchorId,
|
||||
seasonRedirectTarget,
|
||||
episodeFocusHref,
|
||||
seriesPlayHref,
|
||||
seriesPlayLabel,
|
||||
groupEpisodesBySeason,
|
||||
initialExpandedSeasons,
|
||||
} from "./seriesNavigation";
|
||||
|
||||
const SERIES = "series-1";
|
||||
|
||||
function ep(id: string, season: number | null, number: number | null): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `S${season}E${number}`,
|
||||
kind: "episode",
|
||||
seriesId: SERIES,
|
||||
parentIndexNumber: season,
|
||||
indexNumber: number,
|
||||
durationMs: 1_000_000,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `Season ${number}`,
|
||||
kind: "season",
|
||||
seriesId: SERIES,
|
||||
indexNumber: number,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
function withProgress(episode: MediaItem, fraction: number): MediaItem {
|
||||
return {
|
||||
...episode,
|
||||
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
describe("seriesPlayHref", () => {
|
||||
// The reported bug: Play resolved the first *season* child and navigated to
|
||||
// /player/<seasonId>, which bounced back to the season-1 page.
|
||||
it("opens the current episode's focus view, never a season or the player", () => {
|
||||
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
|
||||
expect(href).toBe("/library/series-1?episode=s2e4");
|
||||
expect(href).not.toContain("/player/");
|
||||
});
|
||||
|
||||
it("returns null for a series with no episodes so the button can hide", () => {
|
||||
expect(seriesPlayHref(SERIES, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seriesPlayLabel", () => {
|
||||
it("names the episode it will open", () => {
|
||||
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
|
||||
});
|
||||
|
||||
it("says Resume for a part-watched episode", () => {
|
||||
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
|
||||
});
|
||||
|
||||
it("says Play for a barely-started or nearly-finished episode", () => {
|
||||
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
|
||||
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
|
||||
});
|
||||
|
||||
it("degrades to a bare verb when the numbering is unknown", () => {
|
||||
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
|
||||
expect(seriesPlayLabel(null)).toBe("Play");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seasonRedirectTarget", () => {
|
||||
it("sends a season to its series, anchored at that season", () => {
|
||||
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
|
||||
});
|
||||
|
||||
it("returns null when the series is unknown, so the caller can fall back", () => {
|
||||
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
|
||||
expect(seasonRedirectTarget(orphan)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches the anchor the season section renders", () => {
|
||||
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
|
||||
`/library/${SERIES}#${seasonAnchorId(2)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("episodeFocusHref", () => {
|
||||
it("opens an episode inside its series (never a bare episode page)", () => {
|
||||
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
|
||||
});
|
||||
|
||||
it("falls back to the bare item page when the series is unknown", () => {
|
||||
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
|
||||
expect(episodeFocusHref(orphan)).toBe("/library/lone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupEpisodesBySeason", () => {
|
||||
it("groups episodes under their season headers, in season order", () => {
|
||||
const seasons = [seasonHeader(2), seasonHeader(1)];
|
||||
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
|
||||
|
||||
const grouped = groupEpisodesBySeason(seasons, episodes);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
|
||||
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
|
||||
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
|
||||
});
|
||||
|
||||
it("puts specials after the numbered seasons", () => {
|
||||
const grouped = groupEpisodesBySeason(
|
||||
[seasonHeader(0), seasonHeader(1)],
|
||||
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
|
||||
);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
// A flat series: episodes hang off the series, no season folders exist.
|
||||
it("synthesizes headers when the server returned no seasons", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
|
||||
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
|
||||
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
|
||||
});
|
||||
|
||||
it("names a synthesized season 0 'Specials'", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
|
||||
expect(grouped[0].season.name).toBe("Specials");
|
||||
});
|
||||
|
||||
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
|
||||
const ids = grouped.map((g) => g.season.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("drops seasons that have no episodes", () => {
|
||||
const grouped = groupEpisodesBySeason(
|
||||
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
||||
[ep("s2e1", 2, 1)]
|
||||
);
|
||||
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("buckets season-less episodes into season 1 rather than losing them", () => {
|
||||
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialExpandedSeasons", () => {
|
||||
const seasons = groupEpisodesBySeason(
|
||||
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
||||
[
|
||||
ep("s1e1", 1, 1),
|
||||
ep("s2e1", 2, 1),
|
||||
ep("s2e2", 2, 2),
|
||||
ep("s3e1", 3, 1),
|
||||
]
|
||||
);
|
||||
|
||||
it("expands only the season holding the current episode", () => {
|
||||
const expanded = initialExpandedSeasons(seasons, "s2e2");
|
||||
expect([...expanded]).toEqual(["season-2"]);
|
||||
});
|
||||
|
||||
it("also expands the season of a ?episode= deep link", () => {
|
||||
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
|
||||
expect(expanded.has("season-1")).toBe(true);
|
||||
expect(expanded.has("season-3")).toBe(true);
|
||||
expect(expanded.has("season-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses nothing extra when current and focused share a season", () => {
|
||||
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
|
||||
expect([...expanded]).toEqual(["season-2"]);
|
||||
});
|
||||
|
||||
it("falls back to the first season when there is no current episode", () => {
|
||||
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
|
||||
});
|
||||
|
||||
it("falls back to the first season when the current episode is unknown here", () => {
|
||||
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
|
||||
});
|
||||
|
||||
it("returns nothing for a series with no seasons", () => {
|
||||
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Pure navigation/grouping logic for the series detail page.
|
||||
//
|
||||
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
|
||||
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
|
||||
// SortName — and navigate to `/player/<seasonId>`, which the player route
|
||||
// bounced back to `/library/<seasonId>`. Play on a series therefore played
|
||||
// nothing and landed on the season-1 page.
|
||||
//
|
||||
// Note what is NOT here: *which* episode is current. That is domain policy and
|
||||
// lives in Rust (`repository_get_series_current_episode`); this module only
|
||||
// renders and routes around the answer.
|
||||
//
|
||||
// TRACES: UR-062 | DR-102, DR-103
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
export interface SeasonData {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
}
|
||||
|
||||
/** Jellyfin files specials under season 0. */
|
||||
const SPECIALS_SEASON = 0;
|
||||
|
||||
/** Sort key for a season number: specials come after every numbered season. */
|
||||
function seasonRank(seasonNumber: number | null | undefined): number {
|
||||
if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
|
||||
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-page anchor for a season, so a season link scrolls the series' single
|
||||
* continuous episode list instead of opening a page of its own.
|
||||
*/
|
||||
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
|
||||
return `season-${seasonNumber ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a link naming a season should actually go: the series, anchored at that
|
||||
* season. Returns `null` when the season carries no `seriesId` (a deep link into
|
||||
* a stale cache), in which case the caller must keep rendering something rather
|
||||
* than strand the user.
|
||||
*/
|
||||
export function seasonRedirectTarget(season: MediaItem): string | null {
|
||||
if (!season.seriesId) return null;
|
||||
const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
|
||||
return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an episode link should go: the episode in the context of its series
|
||||
* (ux-flows §5B.1 — an episode is never browsed as a bare Episode page).
|
||||
* Falls back to the bare item page only when the series is unknown.
|
||||
*/
|
||||
export function episodeFocusHref(episode: MediaItem): string {
|
||||
if (!episode.seriesId) return `/library/${episode.id}`;
|
||||
return `/library/${episode.seriesId}?episode=${episode.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the series hero button goes.
|
||||
*
|
||||
* The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
|
||||
* *container* navigation and Play on a *leaf* the commitment. Returns `null`
|
||||
* when there is no current episode (an empty series), so the caller can hide
|
||||
* the button rather than link nowhere.
|
||||
*/
|
||||
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
|
||||
if (!current) return null;
|
||||
return `/library/${seriesId}?episode=${current.id}`;
|
||||
}
|
||||
|
||||
/** Fraction of an episode already watched, 0 when unknown. */
|
||||
function progressFraction(episode: MediaItem): number {
|
||||
const position = episode.userData?.playbackPositionMs ?? 0;
|
||||
if (!episode.durationMs || position <= 0) return 0;
|
||||
return position / episode.durationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the series hero button — it names the episode it will open, so the
|
||||
* viewer knows where the button leads before pressing it.
|
||||
*/
|
||||
export function seriesPlayLabel(current: MediaItem | null): string {
|
||||
if (!current) return "Play";
|
||||
|
||||
const fraction = progressFraction(current);
|
||||
const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";
|
||||
|
||||
if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
|
||||
return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a series' episodes under its season headers.
|
||||
*
|
||||
* The episodes arrive from Rust already in series order; this only decides which
|
||||
* header each one renders beneath, and synthesizes a header for any season the
|
||||
* server did not return one for (a flat series, or a season fetch that failed).
|
||||
* Seasons with no episodes are dropped — an empty accordion row is noise.
|
||||
*/
|
||||
export function groupEpisodesBySeason(
|
||||
seasons: MediaItem[],
|
||||
episodes: MediaItem[]
|
||||
): SeasonData[] {
|
||||
const headerFor = new Map<number, MediaItem>();
|
||||
for (const season of seasons) {
|
||||
const number = season.indexNumber ?? season.parentIndexNumber;
|
||||
if (number != null && !headerFor.has(number)) headerFor.set(number, season);
|
||||
}
|
||||
|
||||
const grouped = new Map<number, MediaItem[]>();
|
||||
for (const episode of episodes) {
|
||||
const number = episode.parentIndexNumber ?? 1;
|
||||
const bucket = grouped.get(number);
|
||||
if (bucket) bucket.push(episode);
|
||||
else grouped.set(number, [episode]);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.sort(([a], [b]) => seasonRank(a) - seasonRank(b))
|
||||
.map(([number, seasonEpisodes]) => ({
|
||||
season:
|
||||
headerFor.get(number) ??
|
||||
({
|
||||
...seasonEpisodes[0],
|
||||
id: `synthetic-season-${number}`,
|
||||
kind: "season",
|
||||
indexNumber: number,
|
||||
name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
|
||||
overview: null,
|
||||
} as MediaItem),
|
||||
episodes: seasonEpisodes,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which seasons start expanded.
|
||||
*
|
||||
* Only the one the viewer is in. A ten-season show otherwise renders every
|
||||
* episode of every season at once, burying the one episode they came for. A
|
||||
* `?episode=` deep link expands that episode's season as well, and a show with
|
||||
* no resolved current episode falls back to its first season so the page is
|
||||
* never entirely collapsed.
|
||||
*
|
||||
* Returns season ids (not numbers) so the caller can key state per section,
|
||||
* including the synthesized headers.
|
||||
*/
|
||||
export function initialExpandedSeasons(
|
||||
seasons: SeasonData[],
|
||||
currentEpisodeId: string | null | undefined,
|
||||
focusedEpisodeId?: string | null
|
||||
): Set<string> {
|
||||
if (seasons.length === 0) return new Set();
|
||||
|
||||
const expanded = new Set<string>();
|
||||
for (const id of [currentEpisodeId, focusedEpisodeId]) {
|
||||
if (!id) continue;
|
||||
const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
|
||||
if (owner) expanded.add(owner.season.id);
|
||||
}
|
||||
|
||||
// Nothing matched — open the first season rather than nothing at all.
|
||||
if (expanded.size === 0) expanded.add(seasons[0].season.id);
|
||||
|
||||
return expanded;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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/movies" || path.startsWith("/library/movies/")) return "movies";
|
||||
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
|
||||
// `/library/shows/genres` is the TV genre route despite the differing segment.
|
||||
// `/library/shows/*` is a legacy TV route that now redirects into
|
||||
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
|
||||
// before the redirect lands still scopes to TV.
|
||||
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
|
||||
|
||||
return "all";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-035, UR-038, UR-048 | DR-043, DR-062 -->
|
||||
<!-- TRACES: UR-035, UR-038, UR-048, UR-062 | DR-043, DR-062, DR-102, DR-103 -->
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
@@ -17,6 +17,7 @@
|
||||
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
|
||||
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
||||
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
|
||||
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
|
||||
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
|
||||
import CastSection from "$lib/components/library/CastSection.svelte";
|
||||
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
|
||||
@@ -28,17 +29,27 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
|
||||
|
||||
interface SeasonData {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
}
|
||||
import {
|
||||
groupEpisodesBySeason,
|
||||
seasonAnchorId,
|
||||
seasonRedirectTarget,
|
||||
episodeFocusHref,
|
||||
seriesPlayHref,
|
||||
seriesPlayLabel,
|
||||
initialExpandedSeasons,
|
||||
type SeasonData,
|
||||
} from "$lib/components/library/seriesNavigation";
|
||||
|
||||
let item = $state<MediaItem | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let seasonData = $state<SeasonData[]>([]);
|
||||
let directFetchedEpisode = $state<MediaItem | null>(null);
|
||||
// The episode the viewer is up to. Resolved by Rust (DR-101), not here.
|
||||
let currentEpisode = $state<MediaItem | null>(null);
|
||||
// Season ids whose episode list is open. A reading position, not a saved
|
||||
// preference, so it resets with each load (DR-107).
|
||||
let expandedSeasons = $state<Set<string>>(new Set());
|
||||
|
||||
// Track if we've done an initial load and previous server state
|
||||
let hasLoadedOnce = false;
|
||||
@@ -81,10 +92,25 @@
|
||||
error = null;
|
||||
seasonData = [];
|
||||
directFetchedEpisode = null;
|
||||
currentEpisode = null;
|
||||
expandedSeasons = new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
item = await library.loadItem(itemId);
|
||||
|
||||
// A season is not a destination — send it to its series, anchored at that
|
||||
// season, so the episodes of every season stay one continuous list.
|
||||
// TRACES: UR-062 | DR-103
|
||||
if (item?.kind === "season") {
|
||||
const target = seasonRedirectTarget(item);
|
||||
if (target) {
|
||||
await goto(target, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
// No seriesId (stale cache / deep link) — fall through to the generic
|
||||
// rendering below rather than stranding the user.
|
||||
}
|
||||
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
|
||||
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||
if (item?.people) {
|
||||
@@ -129,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") {
|
||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||
const repo = auth.getRepository();
|
||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||
|
||||
// Load episodes for each season in parallel
|
||||
const seasonDataPromises = seasons.map(async (season) => {
|
||||
const result = await repo.getItems(season.id, { limit: 100 });
|
||||
const episodes = result.items
|
||||
.filter((i) => i.kind === "episode")
|
||||
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
|
||||
return { season, episodes };
|
||||
});
|
||||
const [episodes, current] = await Promise.all([
|
||||
repo.getSeriesEpisodes(itemId),
|
||||
// Best-effort: a series still renders if the anchor cannot be resolved.
|
||||
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
||||
console.warn("Could not resolve the current episode:", e);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
seasonData = await Promise.all(seasonDataPromises);
|
||||
// Sort seasons by index number
|
||||
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
|
||||
|
||||
// Some series expose episodes directly as children rather than under
|
||||
// season folders. In that case the season fetch above yields nothing —
|
||||
// group the flat episode children by their season number so the Episode
|
||||
// Focus View still has a populated `allEpisodes` (otherwise "More
|
||||
// Episodes" collapses to just the current episode).
|
||||
if (seasonData.every((s) => s.episodes.length === 0)) {
|
||||
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
|
||||
if (flatEpisodes.length > 0) {
|
||||
const bySeason = new Map<number, MediaItem[]>();
|
||||
for (const ep of flatEpisodes) {
|
||||
const key = ep.parentIndexNumber ?? 1;
|
||||
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
|
||||
}
|
||||
seasonData = [...bySeason.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([seasonNumber, episodes]) => ({
|
||||
// Synthesize a minimal season header from the episodes we have.
|
||||
season: {
|
||||
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
|
||||
kind: "season",
|
||||
indexNumber: seasonNumber,
|
||||
name: `Season ${seasonNumber}`,
|
||||
} as MediaItem,
|
||||
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
seasonData = groupEpisodesBySeason(seasons, episodes);
|
||||
currentEpisode = current;
|
||||
// Open only the season the viewer is in (DR-107).
|
||||
expandedSeasons = initialExpandedSeasons(
|
||||
seasonData,
|
||||
current?.id,
|
||||
$page.url.searchParams.get("episode")
|
||||
);
|
||||
|
||||
// If we have a focused episode ID but couldn't find it in the seasons,
|
||||
// fetch it directly (handles ID mismatch between APIs)
|
||||
const episodeIdParam = $page.url.searchParams.get("episode");
|
||||
if (episodeIdParam) {
|
||||
const allEps = seasonData.flatMap((s) => s.episodes);
|
||||
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
|
||||
if (!foundInSeasons) {
|
||||
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
|
||||
try {
|
||||
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
||||
} catch {
|
||||
@@ -189,7 +194,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load item";
|
||||
} finally {
|
||||
@@ -224,14 +228,21 @@
|
||||
return;
|
||||
}
|
||||
switch (clickedItem.kind) {
|
||||
case "series":
|
||||
// A season link lands on its series, anchored at that season — seasons
|
||||
// have no page of their own (DR-103).
|
||||
case "season":
|
||||
goto(seasonRedirectTarget(clickedItem) ?? `/library/${clickedItem.id}`);
|
||||
break;
|
||||
// An episode always opens in the context of its series (ux-flows §5B.1).
|
||||
case "episode":
|
||||
goto(episodeFocusHref(clickedItem));
|
||||
break;
|
||||
case "series":
|
||||
case "album":
|
||||
case "artist":
|
||||
case "folder":
|
||||
case "playlist":
|
||||
case "channel":
|
||||
case "episode":
|
||||
case "movie":
|
||||
goto(`/library/${clickedItem.id}`);
|
||||
break;
|
||||
@@ -244,15 +255,29 @@
|
||||
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
|
||||
// This fixes Android playback issues where navigation-based approach was hanging
|
||||
|
||||
function toggleSeason(seasonId: string) {
|
||||
// Reassign rather than mutate — a Set mutation is invisible to $state.
|
||||
const next = new Set(expandedSeasons);
|
||||
if (!next.delete(seasonId)) next.add(seasonId);
|
||||
expandedSeasons = next;
|
||||
}
|
||||
|
||||
function handleEpisodeClick(episode: MediaItem) {
|
||||
// Play the episode with the series queued for next episode
|
||||
goto(`/player/${episode.id}`);
|
||||
// Swap focus to the episode in place; playback starts from the focus view's
|
||||
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
|
||||
goto(episodeFocusHref(episode));
|
||||
}
|
||||
|
||||
async function handlePlayAll() {
|
||||
// For single items (Episode, Movie), play the item directly
|
||||
if (item?.kind === "episode" || item?.kind === "movie") {
|
||||
goto(`/player/${itemId}`);
|
||||
} else if (item?.kind === "series" && itemId) {
|
||||
// Open the episode the viewer is up to, where an explicit Play/Resume
|
||||
// commits. Play on a container navigates; Play on a leaf plays.
|
||||
// TRACES: UR-062 | DR-102
|
||||
const target = seriesPlayHref(itemId, currentEpisode);
|
||||
if (target) goto(target);
|
||||
} else if (item?.kind === "album" && $libraryItems.length > 0) {
|
||||
// For albums, use the backend command (backend fetches and queues all tracks)
|
||||
try {
|
||||
@@ -293,6 +318,11 @@
|
||||
console.error("Failed to shuffle play album:", e);
|
||||
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
} else if (item?.kind === "series" && allEpisodes.length > 0) {
|
||||
// Shuffle a *series* means a random episode, not a random season — the
|
||||
// player has nothing to do with a season id.
|
||||
const random = allEpisodes[Math.floor(Math.random() * allEpisodes.length)];
|
||||
goto(`/player/${random.id}?restart=true`);
|
||||
} else if ($libraryItems.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
|
||||
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
|
||||
@@ -304,6 +334,10 @@
|
||||
seasonData.flatMap((s) => s.episodes)
|
||||
);
|
||||
|
||||
const playLabel = $derived(item?.kind === "series" ? seriesPlayLabel(currentEpisode) : "Play");
|
||||
// An empty series has nowhere for the hero button to lead.
|
||||
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
|
||||
|
||||
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
|
||||
const focusedEpisode = $derived(
|
||||
focusedEpisodeId
|
||||
@@ -422,9 +456,11 @@
|
||||
{/if}
|
||||
{#if item.parentIndexNumber || item.indexNumber}
|
||||
<p class="text-lg text-gray-400 mt-1">
|
||||
{#if item.seasonId && item.parentIndexNumber}
|
||||
<!-- Links to the season's place in the series list, not to a
|
||||
season page — seasons have none (DR-103). -->
|
||||
{#if item.seriesId && item.parentIndexNumber}
|
||||
<a
|
||||
href={`/library/${item.seasonId}`}
|
||||
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
|
||||
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
>Season {item.parentIndexNumber}</a>
|
||||
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
||||
@@ -466,6 +502,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
{#if canPlay}
|
||||
<button
|
||||
onclick={handlePlayAll}
|
||||
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
@@ -473,8 +510,9 @@
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play
|
||||
{playLabel}
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.kind !== "episode" && item.kind !== "movie"}
|
||||
<button
|
||||
onclick={handleShufflePlay}
|
||||
@@ -498,6 +536,12 @@
|
||||
seriesName={item.name}
|
||||
episodeCount={allEpisodes.length || undefined}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
scope="series"
|
||||
onCleared={loadItem}
|
||||
/>
|
||||
{:else if item.kind === "movie"}
|
||||
<VideoDownloadButton
|
||||
itemId={item.id}
|
||||
@@ -627,7 +671,11 @@
|
||||
{season}
|
||||
{episodes}
|
||||
focusedEpisodeId={focusedEpisodeId ?? undefined}
|
||||
currentEpisodeId={currentEpisode?.id}
|
||||
expanded={expandedSeasons.has(season.id)}
|
||||
onToggle={() => toggleSeason(season.id)}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
onHistoryCleared={loadItem}
|
||||
/>
|
||||
{/each}
|
||||
{/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">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigateUp } from "$lib/utils/navigation";
|
||||
import { library, currentLibrary } from "$lib/stores/library";
|
||||
@@ -9,32 +18,47 @@
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.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";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
const BASE_PATH = "/library/movies";
|
||||
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: "all",
|
||||
name: "All Movies",
|
||||
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
|
||||
description: "Browse all movies",
|
||||
route: "/library/movies/all",
|
||||
},
|
||||
{
|
||||
id: "genres",
|
||||
name: "Genres",
|
||||
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",
|
||||
description: "Browse by genre",
|
||||
route: "/library/movies/genres",
|
||||
},
|
||||
];
|
||||
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
|
||||
|
||||
const tabLabels: Record<LibraryView, string> = {
|
||||
browse: "Browse",
|
||||
all: "All Movies",
|
||||
genres: "Genres",
|
||||
};
|
||||
|
||||
const allMoviesConfig = {
|
||||
itemType: "Movie" as const,
|
||||
title: "Movies",
|
||||
backPath: BASE_PATH,
|
||||
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,
|
||||
};
|
||||
|
||||
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() {
|
||||
if (!$currentLibrary) {
|
||||
@@ -70,19 +94,12 @@
|
||||
const genreRows = $derived($movies.genreRows);
|
||||
const isLoading = $derived($movies.isLoading);
|
||||
const hasContent = $derived(
|
||||
heroItems.length > 0 ||
|
||||
continueWatching.length > 0 ||
|
||||
recentlyAdded.length > 0
|
||||
heroItems.length > 0 || continueWatching.length > 0 || recentlyAdded.length > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
{#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 pb-8">
|
||||
<!-- Header -->
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- Header — one per page, shared by every tab -->
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
|
||||
<button
|
||||
@@ -97,6 +114,22 @@
|
||||
</button>
|
||||
</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 -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
@@ -117,7 +150,7 @@
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/movies/all")}
|
||||
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -127,35 +160,13 @@
|
||||
title={row.name}
|
||||
items={row.items}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto(`/library/movies/genres`)}
|
||||
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if !hasContent}
|
||||
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
|
||||
{/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 class="min-w-0">
|
||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigateUp } from "$lib/utils/navigation";
|
||||
import { library, currentLibrary } from "$lib/stores/library";
|
||||
@@ -9,32 +18,48 @@
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.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";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
const BASE_PATH = "/library/tv";
|
||||
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: "shows",
|
||||
name: "All Shows",
|
||||
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",
|
||||
description: "Browse all series",
|
||||
route: "/library/tv/shows",
|
||||
},
|
||||
{
|
||||
id: "genres",
|
||||
name: "Genres",
|
||||
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",
|
||||
description: "Browse by genre",
|
||||
route: "/library/shows/genres",
|
||||
},
|
||||
];
|
||||
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
|
||||
|
||||
const tabLabels: Record<LibraryView, string> = {
|
||||
browse: "Browse",
|
||||
all: "All Shows",
|
||||
genres: "Genres",
|
||||
};
|
||||
|
||||
const allShowsConfig = {
|
||||
itemType: "Series" as const,
|
||||
title: "TV Shows",
|
||||
backPath: BASE_PATH,
|
||||
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,
|
||||
};
|
||||
|
||||
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() {
|
||||
if (!$currentLibrary) {
|
||||
@@ -57,13 +82,20 @@
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
switch (item.type) {
|
||||
case "Series":
|
||||
// A season lands on its series, anchored at that season (DR-103).
|
||||
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":
|
||||
goto(`/library/${item.id}`);
|
||||
break;
|
||||
default:
|
||||
// Episodes and movies play directly.
|
||||
goto(`/player/${item.id}`);
|
||||
break;
|
||||
}
|
||||
@@ -83,13 +115,8 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
{#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 pb-8">
|
||||
<!-- Header -->
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- Header — one per page, shared by every tab -->
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
||||
<button
|
||||
@@ -104,6 +131,22 @@
|
||||
</button>
|
||||
</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 -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
@@ -120,11 +163,7 @@
|
||||
|
||||
<!-- Next Up -->
|
||||
{#if nextUp.length > 0}
|
||||
<Carousel
|
||||
title="Next Up"
|
||||
items={nextUp}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Carousel title="Next Up" items={nextUp} onItemClick={handleItemClick} />
|
||||
{/if}
|
||||
|
||||
<!-- Recently Added -->
|
||||
@@ -133,7 +172,7 @@
|
||||
title="Recently Added"
|
||||
items={recentlyAdded}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto("/library/tv/shows")}
|
||||
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -143,35 +182,13 @@
|
||||
title={row.name}
|
||||
items={row.items}
|
||||
onItemClick={handleItemClick}
|
||||
showAll={() => goto(`/library/shows/genres`)}
|
||||
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if !hasContent}
|
||||
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
|
||||
{/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 class="min-w-0">
|
||||
<div class="text-white font-semibold truncate">{category.name}</div>
|
||||
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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 AudioPlayer from "$lib/components/player/AudioPlayer.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 {
|
||||
reportPlaybackStart,
|
||||
@@ -72,6 +73,10 @@
|
||||
let pollInterval: ReturnType<typeof setInterval> | 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(() => {
|
||||
// Start position polling (only for audio via MPV backend)
|
||||
pollInterval = setInterval(updateStatus, 1000);
|
||||
@@ -137,30 +142,33 @@
|
||||
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
|
||||
// channel leaf items that carry a video stream).
|
||||
isLive = item.kind === "liveChannel";
|
||||
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
|
||||
// This prevents audio from continuing in the background and clears stale state
|
||||
if (isVideo) {
|
||||
@@ -650,10 +658,6 @@
|
||||
</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}
|
||||
<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">
|
||||
@@ -667,7 +671,13 @@
|
||||
</button>
|
||||
</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
|
||||
media={currentMedia}
|
||||
{streamUrl}
|
||||
|
||||
Reference in New Issue
Block a user