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:
@@ -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>
|
||||
Reference in New Issue
Block a user