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,40 @@
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
const PlayerModal = lazy(() => import("./PlayerModal"));
/** The shell's player, for an address that names one over a page that hosts none of its own (the
* channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null);
useOpenLinkedPlayer(
own,
() => undefined,
(v) => setVideo(v),
);
const onState = useStableCallback((id: string, status: VideoStatus) => {
const write = writeVideoState(qc, id, status);
write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
return write;
});
const close = useStableCallback(() => setVideo(null));
if (!video) return null;
return (
<Suspense fallback={null}>
<PlayerModal video={video} startAt={null} onClose={close} onState={onState} />
</Suspense>
);
}
@@ -111,6 +111,7 @@ function renderModal(ui: ReactNode) {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
started.length = 0;
loaded.length = 0;
seeks.length = 0;
+32 -3
View File
@@ -68,6 +68,7 @@ import {
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history";
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder";
@@ -222,8 +223,6 @@ export default function PlayerModal({
const { openChannel } = useNavigationActions();
const qc = useQueryClient();
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
// opened with, for its whole session. The live feed refetches and reorders/drops items under
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
@@ -277,6 +276,25 @@ export default function PlayerModal({
setRelatedOf(active.id);
setRelated(null);
}
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
// related video playing in place over it and the panel's tab come back with it. Read once.
const [linked] = useState(() => {
const here = linkedPlayer();
return here && here.watch === video.id ? here : null;
});
const linkedRelated = useQuery({
queryKey: qk.videoRow(linked?.related ?? ""),
queryFn: () => api.videoRow(linked!.related!),
enabled: !!linked?.related,
retry: false,
});
const [relatedAdopted, setRelatedAdopted] = useState(false);
if (!relatedAdopted && linkedRelated.data) {
setRelatedAdopted(true);
// Only over the item it was named with: a step of the queue before it arrived left that one.
if (active.id === linked?.watch && !related)
setRelated({ video: linkedRelated.data, startAt: null });
}
const subject = related?.video ?? active;
const subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0)
@@ -753,7 +771,7 @@ export default function PlayerModal({
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
const [relatedOpen, setRelatedOpen] = useState(false);
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
// tab instead. Left open, it reopened unasked when the window narrowed again.
@@ -762,6 +780,17 @@ export default function PlayerModal({
setPanelTab("related");
}
const panelTabs = useTabPanel();
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
// bookmark reopens it as it is; the page's own entry under it never carries them.
useBackToClose(
onClose,
playerParamUpdates({
watch: active.id,
related: related?.video.id ?? null,
panel: wide ? panelTab : "description",
}),
);
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused.
const relatedOn = useMe().related_enabled;
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
//
// The URL is the view: the YouTube player names what it plays in its own history entry — the item of
// the queue, a related video played in place over it, the wide panel's tab — so a reload or a bookmark
// reopens it as it was, and Back lands on the page's address without it. Its own file: the overlay
// history is module state, and these read the address the closed players of other tests step back over.
//
// `window.YT` is a fake installed before the player's API loader first runs (see PlayerModal.dom.test).
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } 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: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { api, type Me, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const started: { videoId: string; start: number | undefined }[] = [];
let playerEvents: { onReady?: () => void } = {};
const fakePlayer = new Proxy(
{ getCurrentTime: () => 0, getDuration: () => 3000, getVideoData: () => null },
{ get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined) },
);
window.YT = {
Player: function (
_el: unknown,
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
playerEvents = opts.events ?? {};
return fakePlayer;
} as never,
};
const lastStart = () => started[started.length - 1];
const vid = (id: string, position_seconds = 0): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const related = (id: string, position_seconds = 0) => ({
...vid(id, position_seconds),
followed: false,
});
// The wide layout (the side panel with its tabs): jsdom has no matchMedia, so one is put in.
function wideWindow() {
window.matchMedia = ((q: string) => ({
matches: q === "(min-width: 1280px)",
media: q,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
function renderModal(video: Video, queue: Video[]) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { related_enabled: true } as Me);
return render(
<QueryClientProvider client={qc}>
<PlayerModal
video={video}
queue={queue}
onClose={() => {}}
onState={() => Promise.resolve()}
/>
</QueryClientProvider>,
);
}
// A closed player steps back over its entry a task later (jsdom's back(), like a browser's).
async function closeAndLand(unmount: () => void) {
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
unmount();
await act(() => landed);
}
const here = () => new URLSearchParams(window.location.search);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
});
afterEach(() => {
vi.restoreAllMocks();
delete (window as { matchMedia?: unknown }).matchMedia;
started.length = 0;
});
describe("the player's address", () => {
it("names the item playing now in its own entry, a step of the queue too", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=talks");
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
await waitFor(() => expect(here().get("watch")).toBe("A"));
expect(here().get("q")).toBe("talks");
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(here().get("watch")).toBe("B"));
await closeAndLand(r.unmount);
expect(window.location.search).toBe("?q=talks");
});
it("wide: names the related video playing over it and the panel's tab, and Back drops it", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/");
const r = renderModal(vid("A", 40), [vid("A", 40)]);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
await waitFor(() => expect(here().get("wtab")).toBe("related"));
const rows = await screen.findAllByRole("link", { name: "Video rel2" });
fireEvent.click(rows.find((a) => a.hasAttribute("data-nav-open"))!);
await waitFor(() => expect(here().get("wrel")).toBe("rel2"));
expect(here().get("watch")).toBe("A");
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(here().has("wrel")).toBe(false));
expect(here().get("watch")).toBe("A");
await closeAndLand(r.unmount);
expect(window.location.search).toBe("");
});
it("wide: a link's related video and panel tab come back over the item it names", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2&wtab=related");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
const r = renderModal(vid("A", 40), [vid("A", 40)]);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
expect(row).toHaveBeenCalledWith("rel2");
const tab = screen.getByRole("tab", { name: "player.related.title" });
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
expect(here().get("wrel")).toBe("rel2");
await closeAndLand(r.unmount);
// The entry the link opened at named the player; once the player is gone, so is its name.
expect(window.location.search).toBe("");
});
it("wide: a link naming another item brings none of its view", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
const r = renderModal(vid("A", 40), [vid("A", 40)]);
await waitFor(() => expect(here().get("watch")).toBe("A"));
expect(row).not.toHaveBeenCalled();
expect(here().has("wrel")).toBe(false);
expect(here().has("wtab")).toBe(false);
expect(lastStart()?.videoId).toBe("A");
await closeAndLand(r.unmount);
});
});
@@ -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();
});
});
@@ -0,0 +1,39 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { linkedPlayer } from "../../lib/playerUrlState";
import { notifyActionError } from "../../lib/actionErrors";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
* the catalog. A row the catalog no longer has is said, not swallowed. */
export function useOpenLinkedPlayer(
ready: boolean,
find: (id: string) => Video | undefined,
open: (video: Video, inList: boolean) => void,
): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
// Opened (or said to be gone) once; a ref, as nothing on screen reads it.
const done = useRef(linked == null);
const listed = ready && linked != null ? find(linked) : undefined;
const row = useQuery({
queryKey: qk.videoRow(linked ?? ""),
queryFn: () => api.videoRow(linked!),
enabled: ready && linked != null && !listed,
retry: false,
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => notifyActionError(err, t("player.linkedFailed")));
useEffect(() => {
if (done.current) return;
if (listed) openIt(listed, true);
else if (row.data) openIt(row.data, false);
else if (row.isError) failed(row.error);
else return;
done.current = true;
}, [listed, row.data, row.isError, row.error]);
}