diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts index 664e92a3..5c9bc107 100644 --- a/frontend/src/components/plexSubUrl.test.ts +++ b/frontend/src/components/plexSubUrl.test.ts @@ -76,3 +76,18 @@ describe("PLEX_SUB_URL decode guards", () => { expect(U.decode("")).toBeNull(); }); }); + +describe("PLEX_SUB_URL entry", () => { + // E28 review E1: the Restart click must not replay on a reload / Forward onto the player entry. + it("keeps the player without its one-shot fromStart, and every other view whole", () => { + const entry = U.entry!; + expect(entry({ kind: "player", id: "7", queue: ["7", "8"], fromStart: true })).toEqual({ + kind: "player", + id: "7", + queue: ["7", "8"], + fromStart: undefined, + }); + const info = { kind: "info", id: "7" } as const; + expect(entry(info)).toBe(info); + }); +}); diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index cc5e2735..6e59317e 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -49,6 +49,10 @@ export const PLEX_SUB_URL: SubviewUrl = { return null; // discover (root) + the ephemeral player are not URL-mirrored } }, + // `fromStart` is the Restart CLICK, not a property of the entry: kept in history.state it restarted + // the item from 0 again on every reload or Forward onto the player, and the progress checkpoint + // then overwrote the resume point it had skipped (E28 review E1). + entry: (v) => (v.kind === "player" && v.fromStart ? { ...v, fromStart: undefined } : v), decode: (raw) => { const i = raw.indexOf(":"); const kind = i === -1 ? raw : raw.slice(0, i); diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index 1705a92a..fde4b97a 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -138,3 +138,35 @@ describe("useHistorySubview popstate", () => { expect(result.current.view).toBe("root"); }); }); + +// E28 review E1: a one-shot flag the view is OPENED with (the Plex player's `fromStart`, a card's +// Restart) rode in history.state._sub, so a reload or a Forward onto that entry restarted from 0 +// again — and the player's checkpoint then overwrote the resume point. `entry` says what the +// history entry keeps of a view; the view itself still opens with the flag. +describe("useHistorySubview entry", () => { + type V = { kind: string; once?: boolean }; + const withEntry: SubviewUrl = { + key: "v", + encode: () => null, + decode: () => null, + entry: (v) => ({ kind: v.kind }), + }; + + it("opens the view with the flag, but the history entry keeps it without", () => { + const { result } = renderHook(() => useHistorySubview({ kind: "root" }, withEntry)); + act(() => result.current.open({ kind: "player", once: true })); + expect(result.current.view).toEqual({ kind: "player", once: true }); + expect(window.history.state._sub).toEqual({ kind: "player" }); + // A reload of that entry reads what the entry kept. + const { result: reloaded } = renderHook(() => + useHistorySubview({ kind: "root" }, withEntry), + ); + expect(reloaded.current.view).toEqual({ kind: "player" }); + }); + + it("keeps it out of a replaced entry too", () => { + const { result } = renderHook(() => useHistorySubview({ kind: "root" }, withEntry)); + act(() => result.current.open({ kind: "player", once: true }, { replace: true })); + expect(window.history.state._sub).toEqual({ kind: "player" }); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 7ea6322b..d115d6a6 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -106,6 +106,9 @@ export interface SubviewUrl { encode: (view: T) => string | null; /** Parse a param value back to a sub-view, or null if it isn't a valid one. */ decode: (raw: string) => T | null; + /** What the history ENTRY keeps of a view, when that is less than the view opens with: a one-shot + * flag (the Plex player's `fromStart`) must not replay on a reload or a Forward onto the entry. */ + entry?: (view: T) => T; } export function useHistorySubview( @@ -152,19 +155,20 @@ export function useHistorySubview( const open = useCallback( (next: T, opts?: { replace?: boolean }) => { setView(next); + const kept = url?.entry ? url.entry(next) : next; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; if (opts?.replace) { // Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and // the view key has changed, so the sub-view still opens at the top without minting an id // that would orphan the one this entry already carries. - window.history.replaceState({ ...window.history.state, _sub: next }, "", href); + window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); notifyNav(); return; } // A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back // restores the view we came from) — then notify PageScroller, which this push doesn't re-render. window.history.pushState( - { ...rememberNavPosition(), _sub: next, sfScroll: nextNavId() }, + { ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() }, "", href, );