feat(player): the YouTube player and the file viewer are named in the address

The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
This commit is contained in:
2026-10-27 05:29:49 +01:00
parent d4f24de4ba
commit fa893c73a3
45 changed files with 1005 additions and 89 deletions
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
// The player itself is not what is under test: it shows what it was opened on.
vi.mock("./PlayerModal", () => ({
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
<p>
playing {video.id} {queue ? "in a list" : "alone"}
</p>
),
}));
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.mocked(notifyActionError).mockClear();
window.history.replaceState(null, "", "/");
});
describe("useOpenLinkedPlayer", () => {
it("opens the item from the host's list once the list is in, and only once", async () => {
window.history.replaceState(null, "", "/?watch=B");
const row = vi.spyOn(api, "videoRow");
const open = vi.fn();
const list = [vid("A"), vid("B")];
const { rerender } = renderHook(
({ ready }) => useOpenLinkedPlayer(ready, (id) => list.find((v) => v.id === id), open),
{ wrapper, initialProps: { ready: false } },
);
expect(open).not.toHaveBeenCalled();
rerender({ ready: true });
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
rerender({ ready: true });
expect(open).toHaveBeenCalledTimes(1);
expect(row).not.toHaveBeenCalled();
});
it("opens an item the list does not hold alone, its row from the catalog", async () => {
window.history.replaceState(null, "", "/?watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
});
it("says so when the catalog no longer has it", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const gone = new Error("not found");
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
);
expect(open).not.toHaveBeenCalled();
});
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
window.history.replaceState(null, "", "/?q=talks");
const row = vi.spyOn(api, "videoRow");
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await new Promise((r) => setTimeout(r, 20));
expect(open).not.toHaveBeenCalled();
expect(row).not.toHaveBeenCalled();
});
});
describe("the shell's LinkedPlayer", () => {
it("plays the video alone over a page that hosts no player", async () => {
window.history.replaceState(null, "", "/?page=channels&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
render(<LinkedPlayer hosted={false} />, { wrapper });
expect(await screen.findByText("playing Z alone")).toBeTruthy();
});
it("leaves it to a page that hosts its own", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
render(<LinkedPlayer hosted />, { wrapper });
await new Promise((r) => setTimeout(r, 20));
expect(row).not.toHaveBeenCalled();
expect(screen.queryByText(/playing/)).toBeNull();
});
});