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
13 KiB
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, §5B.2, §5B.4, §5B.5
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:
/// 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:
#[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()callsrepositoryGetSeriesEpisodesonce instead of fanning out over seasons itself, andrepositoryGetSeriesCurrentEpisodefor the anchor. Season headers still come fromget_items(seriesId); the page groups the returned episodes under them byparentIndexNumber.- No
?episode=param → series view,SeasonSectionreceivescurrentEpisodeId,EpisodeRowrenders the highlight and scrolls itself into view (scrollIntoView({ block: "center" }), the existingfocusedmechanism, 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
#[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_itemis still season-bounded, so autoplay stops at a season boundary. Fixing it should reuserepository_get_series_episodes, but it touches the playback state machine and the Android JNI advance path (see theAutoplayDecisiondeadlock 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).