fix(plex): a Restart does not replay on a reload or a Forward onto the player

fromStart rode in history.state._sub, so reloading the player entry restarted
the item from 0 again and the progress checkpoint overwrote its resume point.
A sub-view config can now say what the history entry keeps of a view.
This commit is contained in:
2026-09-27 14:03:48 +02:00
parent de62035889
commit 383c85231a
4 changed files with 57 additions and 2 deletions
@@ -76,3 +76,18 @@ describe("PLEX_SUB_URL decode guards", () => {
expect(U.decode("")).toBeNull(); 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);
});
});
+4
View File
@@ -49,6 +49,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
return null; // discover (root) + the ephemeral player are not URL-mirrored 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) => { decode: (raw) => {
const i = raw.indexOf(":"); const i = raw.indexOf(":");
const kind = i === -1 ? raw : raw.slice(0, i); const kind = i === -1 ? raw : raw.slice(0, i);
@@ -138,3 +138,35 @@ describe("useHistorySubview popstate", () => {
expect(result.current.view).toBe("root"); 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<V> = {
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<V>({ 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<V>({ kind: "root" }, withEntry),
);
expect(reloaded.current.view).toEqual({ kind: "player" });
});
it("keeps it out of a replaced entry too", () => {
const { result } = renderHook(() => useHistorySubview<V>({ kind: "root" }, withEntry));
act(() => result.current.open({ kind: "player", once: true }, { replace: true }));
expect(window.history.state._sub).toEqual({ kind: "player" });
});
});
+6 -2
View File
@@ -106,6 +106,9 @@ export interface SubviewUrl<T> {
encode: (view: T) => string | null; encode: (view: T) => string | null;
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */ /** Parse a param value back to a sub-view, or null if it isn't a valid one. */
decode: (raw: string) => T | null; 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<T>( export function useHistorySubview<T>(
@@ -152,19 +155,20 @@ export function useHistorySubview<T>(
const open = useCallback( const open = useCallback(
(next: T, opts?: { replace?: boolean }) => { (next: T, opts?: { replace?: boolean }) => {
setView(next); setView(next);
const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
if (opts?.replace) { if (opts?.replace) {
// Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and // 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 // 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. // 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(); notifyNav();
return; return;
} }
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back // 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. // restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState( window.history.pushState(
{ ...rememberNavPosition(), _sub: next, sfScroll: nextNavId() }, { ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() },
"", "",
href, href,
); );