fix(plex): a reload during a binge reopens the episode playing now

The player stepped to the next item in its own state while the history entry still held the item it
opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the
page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the
player stay open, and the entry under an overlay is rewritten when a step lands back on it). The
player's URL is the item playing (`?v=player:<id>`), so a bookmark opens it; the list it plays from
stays in the history entry.
This commit is contained in:
2026-10-25 23:24:29 +01:00
parent b30c60d1a5
commit 9bd7304bb9
14 changed files with 302 additions and 28 deletions
@@ -185,6 +185,84 @@ describe("useHistorySubview entry", () => {
});
});
// A view that moves on by itself — the Plex player stepping to the next episode — is still the same
// entry: no push, nothing for Back to step through. But the entry must say where it moved, or a reload
// reopens the item the viewer started with (the owner, 2026-10-25: F5 during a binge restarted the
// first episode).
describe("useHistorySubview follow", () => {
async function overlayOver() {
const closed = vi.fn();
const layer = renderHook(() => useBackToClose(closed));
await act(async () => {
await Promise.resolve();
});
return { closed, layer };
}
it("rewrites the entry it stands on, so a reload reopens the view it moved on to", () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
const before = window.history.length;
act(() => result.current.follow("b"));
expect(result.current.view).toBe("b");
expect(window.history.state?._sub).toBe("b");
expect(window.location.search).toBe("?v=b");
expect(window.history.length).toBe(before);
const { result: reloaded } = renderHook(() => useHistorySubview("root", url));
expect(reloaded.current.view).toBe("b");
});
it("is not a navigation: an overlay over the view stays open", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
const { closed, layer } = await overlayOver();
act(() => result.current.follow("b"));
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
expect(closed).not.toHaveBeenCalled();
expect(window.history.state?._ov).toBeDefined();
// A reload with the overlay up reopens the view it moved on to as well.
expect(window.history.state?._sub).toBe("b");
layer.unmount(); // the history goes back to the view's own entry before the next test
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
});
it("the entry under an overlay follows too: closing the overlay lands on the moved-on view", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
const { layer } = await overlayOver();
act(() => result.current.follow("b"));
layer.unmount(); // the overlay closes: its entry goes by a back() onto the view's own entry
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
expect(result.current.view).toBe("b");
expect(window.history.state?._sub).toBe("b");
expect(window.location.search).toBe("?v=b");
});
it("a navigation on the same entry ends it: an overlay closing over it lands on the new view", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
act(() => result.current.follow("b"));
act(() => result.current.open("c", { replace: true }));
const { layer } = await overlayOver();
layer.unmount();
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
expect(result.current.view).toBe("c");
expect(window.history.state?._sub).toBe("c");
});
it("does not follow onto another entry: Back to the view below restores that one", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
act(() => result.current.open("p"));
act(() => result.current.follow("q"));
act(() => window.history.back());
await waitFor(() => expect(result.current.view).toBe("a"));
expect(window.history.state?._sub).toBe("a");
});
});
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
// left by a pushState with no user gesture — and that can be this page's own entry under the
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
+37 -9
View File
@@ -9,7 +9,7 @@
// its own button pops exactly its entry without tripping the modals underneath.
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
import { useLatest } from "./useLatest";
@@ -143,6 +143,9 @@ export function useHistorySubview<T>(
* continuation of the navigation that just arrived here, not a step the user took inside the
* module. See the `jumpTo` hand-off in PlexBrowse. */
open: (next: T, opts?: { replace?: boolean }) => void;
/** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands
* on says so from now on, without a navigation — the overlays over it stay open. */
follow: (next: T) => void;
back: () => void;
} {
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
@@ -161,7 +164,16 @@ export function useHistorySubview<T>(
return root;
};
const [view, setView] = useState<T>(readView);
const readLandedView = useEffectEvent(readView);
// Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its
// own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten:
// the view's own entry under the overlay still says where it was until a step lands back on it.
const followedRef = useRef<{ entry: number; view: T } | null>(null);
const readLandedView = useEffectEvent((): T => {
const followed = followedRef.current;
if (followed == null || peekNavId() !== followed.entry) return readView();
writeSubEntry(followed.view, url);
return followed.view;
});
useEffect(() => {
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
const onPop = () => setView(readLandedView());
@@ -182,16 +194,15 @@ export function useHistorySubview<T>(
const go = 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) }) : "";
// A navigation says where the view is now, on the entry it lands on or rewrites.
followedRef.current = null;
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: kept }, "", href);
writeSubEntry(next, url);
notifyNav();
return;
}
const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
// 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(
@@ -218,9 +229,26 @@ export function useHistorySubview<T>(
}),
[go, rootRef],
);
const follow = useCallback(
(next: T) => {
setView(next);
followedRef.current = { entry: ensureNavId(), view: next };
writeSubEntry(next, url);
},
[url],
);
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
// can now write `[sub]` and mean it, instead of re-running on every parent render.
return useMemo(() => ({ view, open, back }), [view, open, back]);
return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]);
}
// Rewrite the CURRENT entry to hold `next`. 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.
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
}
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
//
// A reload reopens the item playing NOW (the owner, 2026-10-25): binge a series, press F5 mid-episode,
// and the player restarted the episode the session started with — the player moved on in its own
// state while the history entry still held the first item. The player says what it moved on to
// (PlexPlayer's own tests); this is the page writing it into the entry and the address bar.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./PlexPlayer", () => ({
default: ({
itemId,
queue,
onItemChange,
}: {
itemId: string;
queue?: string[];
onItemChange: (id: string) => void;
}) => (
<div>
<span>
player {itemId} queue {queue?.join(",") ?? "none"}
</span>
<button onClick={() => onItemChange("e2")}>next</button>
</div>
),
}));
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
import PlexBrowse from "./PlexBrowse";
import { api } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => {
vi.spyOn(api, "plexLibrary").mockResolvedValue({
items: [],
total: 0,
offset: 0,
episodes: [],
} as never);
window.history.replaceState(
{ _sub: { kind: "player", id: "e1", queue: ["e1", "e2", "e3"] }, sfScroll: 4 },
"",
"/?page=plex&v=player:e1",
);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexBrowse />
</QueryClientProvider>,
);
}
describe("the Plex player on a reload", () => {
it("reopens the item it moved on to, with the list it plays from", async () => {
mount();
expect(await screen.findByText("player e1 queue e1,e2,e3")).toBeTruthy();
const before = window.history.length;
fireEvent.click(screen.getByText("next"));
expect(screen.getByText("player e2 queue e1,e2,e3")).toBeTruthy();
expect(window.history.length).toBe(before); // moving on is no step for Back
cleanup(); // F5: the page goes, the history entry stays
mount();
expect(await screen.findByText("player e2 queue e1,e2,e3")).toBeTruthy();
});
it("says the item playing now in the address bar, so a bookmark opens it", async () => {
mount();
fireEvent.click(await screen.findByText("next"));
expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e2");
cleanup();
window.history.replaceState(null, "", window.location.href); // a fresh tab: the URL alone
mount();
expect(await screen.findByText("player e2 queue none")).toBeTruthy();
});
});
+4 -1
View File
@@ -429,6 +429,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
}
if (sub.view.kind === "player") {
const { queue } = sub.view;
// The fallback is portaled for the same reason the player itself is: it is a fixed
// full-viewport overlay, and this tree renders inside the page scroller.
return (
@@ -437,9 +438,11 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
>
<PlexPlayer
itemId={sub.view.id}
queue={sub.view.queue}
queue={queue}
fromStart={sub.view.fromStart}
onClose={sub.back}
// The item playing now, with the list it plays from, is what this entry reopens.
onItemChange={(id) => sub.follow({ kind: "player", id, queue })}
/>
</Suspense>
);
@@ -75,7 +75,7 @@ async function mount() {
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={onClose} />
<PlexPlayer itemId="1" onClose={onClose} onItemChange={() => {}} />
</QueryClientProvider>,
);
await waitFor(() =>
@@ -135,6 +135,7 @@ function mount(opts: {
first?: string;
fromStart?: boolean;
onClose?: () => void;
onItemChange?: (id: string) => void;
}) {
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
const session = vi
@@ -149,6 +150,7 @@ function mount(opts: {
<PlexPlayer
itemId={first}
onClose={opts.onClose ?? (() => {})}
onItemChange={opts.onItemChange ?? (() => {})}
queue={opts.queue}
fromStart={opts.fromStart}
/>
@@ -795,6 +797,40 @@ describe("the C5 row", () => {
});
});
// The page's history entry keeps the item playing now, so a reload reopens THAT one (the owner,
// 2026-10-25: F5 during a binge restarted the episode the session started with). The player says
// every item it moves on to; the page writes it into the entry (PlexBrowse, useHistorySubview).
describe("the item it moves on to", () => {
it("is said on Next", async () => {
const onItemChange = vi.fn();
mount({ details: { A: item("A", 0, "B"), B: item("B") }, onItemChange });
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.next"));
expect(onItemChange).toHaveBeenCalledWith("B");
});
it("is said when an item played to its end advances by itself", async () => {
localStorage.setItem(
"siftlode.plexPlayerPrefs",
JSON.stringify({ subLang: "en", upNext: false }),
);
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
const onItemChange = vi.fn();
mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"], onItemChange });
await playingAt("/media/A", 2990);
act(() => void video().dispatchEvent(new Event("ended")));
expect(onItemChange).toHaveBeenCalledWith("B");
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
});
it("is not said for the item already playing", async () => {
const onItemChange = vi.fn();
mount({ details: { A: item("A", 0, "B"), B: item("B") }, onItemChange });
await playingAt("/media/A", 100);
expect(onItemChange).not.toHaveBeenCalled();
});
});
describe("Back goes by position (C5)", () => {
it("more than 3 s in: back to the start of this item", async () => {
// B opened at its resume point, so a session asked at 0 can only be the restart.
@@ -124,7 +124,7 @@ function mount() {
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="A" onClose={() => {}} />
<PlexPlayer itemId="A" onClose={() => {}} onItemChange={() => {}} />
</QueryClientProvider>,
);
return session;
@@ -75,7 +75,7 @@ async function mount() {
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={onClose} />
<PlexPlayer itemId="1" onClose={onClose} onItemChange={() => {}} />
</QueryClientProvider>,
);
await waitFor(() =>
@@ -127,7 +127,7 @@ function mount(opts: {
const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
const view = render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} />
<PlexPlayer itemId={first} onClose={() => {}} onItemChange={() => {}} queue={opts.queue} />
</QueryClientProvider>,
);
return { ...view, progress, status, qc };
@@ -89,7 +89,7 @@ describe("PlexPlayer under StrictMode", () => {
render(
<StrictMode>
<QueryClientProvider client={qc}>
<PlexPlayer itemId="99079" onClose={() => {}} />
<PlexPlayer itemId="99079" onClose={() => {}} onItemChange={() => {}} />
</QueryClientProvider>
</StrictMode>,
);
@@ -112,7 +112,7 @@ describe("PlexPlayer under StrictMode", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId="99079" onClose={() => {}} />
<PlexPlayer itemId="99079" onClose={() => {}} onItemChange={() => {}} />
</QueryClientProvider>,
);
const video = await waitFor(() => document.querySelector("video")!);
@@ -84,7 +84,7 @@ async function mount() {
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={() => {}} />
<PlexPlayer itemId="1" onClose={() => {}} onItemChange={() => {}} />
</QueryClientProvider>,
);
await waitFor(() =>
+18 -3
View File
@@ -133,10 +133,24 @@ const DOUBLE_TAP_MS = 300;
// the queue instead of the item's own episode neighbours.
// `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it
// moves on to starts at its own resume point.
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
// `onItemChange` hears every item it moves on to (Next, Back, an auto-advance, a collection sibling):
// the page's history entry keeps the item playing NOW, so a reload reopens that one, not the first.
type Props = {
itemId: string;
onClose: () => void;
onItemChange: (id: string) => void;
queue?: string[];
fromStart?: boolean;
};
type Panel = "none" | "more" | "tracks" | "settings" | "info" | "upNext";
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
export default function PlexPlayer({
itemId,
onClose,
onItemChange,
queue,
fromStart = false,
}: Props) {
const { t, i18n } = useTranslation();
// A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
@@ -745,9 +759,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
movedOnRef.current = true;
setAbs(0);
setId(otherId);
onItemChange(otherId);
}
},
[id, engine, seekTo, requestPlay, settleProgress, finishProgress],
[id, engine, seekTo, requestPlay, settleProgress, finishProgress, onItemChange],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
+10 -3
View File
@@ -16,9 +16,15 @@ describe("PLEX_SUB_URL encode", () => {
expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5");
});
it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => {
// The item playing now — the list it plays from rides in the history entry, not the URL.
it("mirrors the player as the item it plays", () => {
expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe(
"player:m9",
);
});
it("does NOT mirror the root Discover landing (encode → null)", () => {
expect(U.encode({ kind: "discover" })).toBeNull();
expect(U.encode({ kind: "player", id: "m9" })).toBeNull();
});
});
@@ -33,6 +39,7 @@ describe("PLEX_SUB_URL round-trip", () => {
{ kind: "person", name: "Jane Doe" },
{ kind: "playlist", id: 12 },
{ kind: "collection", id: "c5" },
{ kind: "player", id: "m9" },
];
it.each(cases)("encode→decode is identity for %o", (view) => {
const raw = U.encode(view);
@@ -67,11 +74,11 @@ describe("PLEX_SUB_URL decode guards", () => {
expect(U.decode("info:")).toBeNull();
expect(U.decode("person:")).toBeNull();
expect(U.decode("collection:")).toBeNull();
expect(U.decode("player:")).toBeNull();
});
it("rejects an unknown kind or garbage", () => {
expect(U.decode("grid")).toBeNull();
expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable
expect(U.decode("bogus")).toBeNull();
expect(U.decode("")).toBeNull();
});
+10 -5
View File
@@ -26,10 +26,11 @@ export type Sub =
| { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key)
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing, so it (and the
// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a
// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing
// ":" round-trips.
// — the flat browse grid, and the item playing — are bookmarkable. Discover is the ROOT landing, so it
// is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a distinct, shareable
// URL. The player's URL is the item playing NOW (the player rewrites it as it moves on); the list it
// plays from rides in the history entry. The person name is the whole tail after the first ":", so a
// name containing ":" round-trips.
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v",
encode: (v) => {
@@ -52,8 +53,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
return `playlist:${v.id}`;
case "collection":
return `collection:${v.id}`;
case "player":
return `player:${v.id}`;
default:
return null; // discover (root) + the ephemeral player are not URL-mirrored
return null; // discover, the root, is not URL-mirrored
}
},
// `fromStart` is the Restart CLICK, not a property of the entry: kept in history.state it restarted
@@ -75,6 +78,8 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
return arg ? { kind: "show", id: arg } : null;
case "info":
return arg ? { kind: "info", id: arg } : null;
case "player":
return arg ? { kind: "player", id: arg } : null;
case "person":
return arg ? { kind: "person", name: arg } : null;
case "playlist": {