feat(player): a bookmark reopens every player as it was, once

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.
This commit is contained in:
2026-10-27 07:33:55 +01:00
parent fa893c73a3
commit 3d26ca0efa
23 changed files with 343 additions and 53 deletions
@@ -165,6 +165,22 @@ describe("the player's address", () => {
expect(window.location.search).toBe("");
});
it("a link's related video that arrives after the queue stepped on stays off the next item", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2");
let arrive: (v: Video) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise<Video>((r) => (arrive = r)));
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(here().get("watch")).toBe("B"));
await act(async () => {
arrive(vid("rel2", 120));
await new Promise((r) => setTimeout(r, 50)); // the query hands its answer on a tick later
});
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
expect(here().has("wrel")).toBe(false);
await closeAndLand(r.unmount);
});
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");
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -48,13 +49,14 @@ describe("useOpenLinkedPlayer", () => {
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 } },
({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open),
{ wrapper, initialProps: { ready: false, rows: list } },
);
expect(open).not.toHaveBeenCalled();
rerender({ ready: true });
rerender({ ready: true, rows: list });
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
rerender({ ready: true });
// The list refetched (new rows for the same videos): the player it opened is not opened again.
rerender({ ready: true, rows: [vid("A"), vid("B")] });
expect(open).toHaveBeenCalledTimes(1);
expect(row).not.toHaveBeenCalled();
});
@@ -77,6 +79,8 @@ describe("useOpenLinkedPlayer", () => {
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
);
expect(open).not.toHaveBeenCalled();
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
});
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
@@ -98,6 +102,20 @@ describe("the shell's LinkedPlayer", () => {
expect(await screen.findByText("playing Z alone")).toBeTruthy();
});
it("leaves it to a page that hosts its own, even once that page has fetched the row", async () => {
// The page plays it alone, from the catalog row it asked for: the same cache entry this one reads.
window.history.replaceState(null, "", "/?page=home&watch=Z");
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.videoRow("Z"), vid("Z"));
render(
<QueryClientProvider client={qc}>
<LinkedPlayer hosted />
</QueryClientProvider>,
);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText(/playing/)).toBeNull();
});
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"));
@@ -3,7 +3,8 @@ 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 { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { dropFromAddress } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
@@ -27,13 +28,19 @@ export function useOpenLinkedPlayer(
retry: false,
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => notifyActionError(err, t("player.linkedFailed")));
const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed"));
dropFromAddress(PLAYER_KEYS);
});
useEffect(() => {
if (done.current) return;
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
// it here too (a disabled query still reads the cache) — the shell's player over a page that
// plays its own would open a second one.
if (done.current || !ready) 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]);
}, [ready, listed, row.data, row.isError, row.error]);
}