feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md
This commit is contained in:
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -254,6 +257,13 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -324,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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
+928
-654
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
|
||||
|
||||
Reference in New Issue
Block a user