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();
});
});