🏗️ 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
240 lines
13 KiB
Markdown
240 lines
13 KiB
Markdown
# 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).
|