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
@@ -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": {