The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark rebuilds the queue from the playlist's own order while the item is still in it. Found in the browser and fixed: - after a reload the shell's player opened a second one over Home and the feed: it read the catalog row the page had fetched (a disabled query still reads the shared cache); it now waits for ready. - every reload under an open overlay left a dead history step: the first overlay to open takes over the entry the document was loaded on. - the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked feed opened as Home: the feed now names itself (page=feed) like every page. - a linked file or video that is gone was said, but stayed in the address and was said again on every reload: its keys are taken off. The overlay address is written in one place (the entry is named once it is current), and the player keys only the codec reads are no longer exported.
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
// @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("./PlexPlaylistView", () => ({
|
|
default: ({ onPlay }: { onPlay: (rk: string, queue: string[]) => void }) => (
|
|
<button onClick={() => onPlay("e1", ["e1", "e2"])}>play from the playlist</button>
|
|
),
|
|
}));
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("the Plex player opened from a playlist", () => {
|
|
const playlist = (ids: string[]) =>
|
|
vi.spyOn(api, "plexPlaylist").mockResolvedValue({
|
|
id: 7,
|
|
title: "Road trip",
|
|
items: ids.map((id) => ({ id, title: id })),
|
|
} as never);
|
|
|
|
it("names the playlist after the item, as it moves on too", async () => {
|
|
window.history.replaceState(null, "", "/?page=plex&v=playlist:7");
|
|
mount();
|
|
fireEvent.click(await screen.findByText("play from the playlist"));
|
|
expect(await screen.findByText("player e1 queue e1,e2")).toBeTruthy();
|
|
expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e1:playlist:7");
|
|
fireEvent.click(screen.getByText("next"));
|
|
expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e2:playlist:7");
|
|
});
|
|
|
|
it("a bookmark plays on through the playlist, its order rebuilt from it", async () => {
|
|
window.history.replaceState(null, "", "/?page=plex&v=player:e2:playlist:7");
|
|
const read = playlist(["e1", "e2", "e3"]);
|
|
mount();
|
|
expect(await screen.findByText("player e2 queue e1,e2,e3")).toBeTruthy();
|
|
expect(read).toHaveBeenCalledWith(7, "en");
|
|
});
|
|
|
|
it("a bookmark whose item has left the playlist plays it on its own neighbours", async () => {
|
|
window.history.replaceState(null, "", "/?page=plex&v=player:e9:playlist:7");
|
|
const read = playlist(["e1", "e2"]);
|
|
mount();
|
|
await vi.waitFor(() => expect(read).toHaveBeenCalled());
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(screen.getByText("player e9 queue none")).toBeTruthy();
|
|
});
|
|
});
|