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
@@ -200,8 +200,11 @@ function mount({
describe("Feed — a player the address names", () => {
it("reopens on its video, the list as its queue", async () => {
window.history.replaceState(null, "", "/?watch=v1");
// The catalog has it too: asked before the list is in, the player would open on it alone.
const row = vi.spyOn(api, "videoRow").mockResolvedValue(video("new"));
mount();
expect(await screen.findByText("playing v1 in v1")).toBeTruthy();
expect(row).not.toHaveBeenCalled();
});
it("plays a video the loaded list does not hold alone, from the catalog", async () => {
@@ -181,6 +181,7 @@ afterEach(() => {
mocks.titleArgs = [];
mocks.wideLoading = false;
mocks.wideFailed = false;
mocks.searched = [];
mocks.notify.mockClear();
resetQueueWatch();
});
@@ -776,6 +777,24 @@ describe("Files · a file the address names", () => {
expect(await screen.findByText("playing 1")).toBeTruthy();
});
it("past the cap, waits for the server's sorted list to look for it — and finds it there", async () => {
window.history.replaceState(null, "", "/?page=files&fsort=name:asc&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT];
mocks.wideLoading = true;
mount();
await new Promise((r) => setTimeout(r, 20));
// The newest files alone do not hold it: not "gone" while the server's answer is on its way.
expect(mocks.notify).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
cleanup();
mocks.wideLoading = false;
mocks.searched = [doc(9999)];
mount();
const dialog = await screen.findByRole("dialog");
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(9999));
}, 30_000); // a full page of FILES_LIST_CAP rows, drawn twice (see "a library past the list's cap")
it("says so when the list no longer holds it", async () => {
window.history.replaceState(null, "", "/?page=files&fview=99");
mocks.files = [doc(2)];
@@ -787,6 +806,8 @@ describe("Files · a file the address names", () => {
),
);
expect(screen.queryByRole("dialog")).toBeNull();
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
expect(here().has("fview")).toBe(false);
});
});
+12 -4
View File
@@ -25,7 +25,13 @@ import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager";
import { useDebounced } from "../../lib/useDebounced";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { FILES_SORT_KEY, openFileFrom } from "../../lib/filesUrlState";
import { dropFromAddress } from "../../lib/urlState";
import {
FILES_PLAY_KEY,
FILES_SORT_KEY,
FILES_VIEW_KEY,
openFileFrom,
} from "../../lib/filesUrlState";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { JobThumb } from "../../features/downloads/downloadRow";
@@ -280,14 +286,16 @@ export default function Files() {
const [viewing, setViewing] = useState<number | null>(null);
// A reload or a bookmark reopens the file the address names — in the viewer or the player — once
// the list it is in has come (its folder, search and sort are in the address too). Once; a file the
// list no longer holds is said.
// list no longer holds is said, and taken off the address.
const [linkedFile] = useState(() => openFileFrom(new URLSearchParams(window.location.search)));
const linkedDone = useRef(linkedFile == null);
const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined;
const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined;
const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
if (!file) notify({ level: "warning", message: t("files.linkedGone") });
else if (linkedFile?.open === "play") play(file);
if (!file) {
notify({ level: "warning", message: t("files.linkedGone") });
dropFromAddress([FILES_VIEW_KEY, FILES_PLAY_KEY]);
} else if (linkedFile?.open === "play") play(file);
else setViewing(file.id);
});
useEffect(() => {
@@ -235,3 +235,13 @@ describe("LibraryPlayer", () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
});
});
describe("LibraryPlayer's address", () => {
it("names the file it plays in its own entry, so a reload or a bookmark reopens it", async () => {
window.history.replaceState(null, "", "/?page=files");
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
await vi.waitFor(() =>
expect(new URLSearchParams(window.location.search).get("fplay")).toBe("7"),
);
});
});
@@ -193,11 +193,14 @@ afterEach(() => {
describe("Playlists — a player the address names", () => {
it("reopens on its video, the playlist's view as the queue", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=B");
// The catalog has it too: asked before the playlist is in, the player would open on it alone.
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B", 0));
mount({ 1: [vid("A", 0), vid("B", 0)] });
await waitFor(() => expect(h.player?.video.id).toBe("B"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["A", "B"]);
expect(h.player!.startIndex).toBe(1);
expect(h.player!.onRemoveFromList).toBeDefined();
expect(row).not.toHaveBeenCalled();
});
it("plays a video the view does not show alone, with nothing to remove it from", async () => {
@@ -30,6 +30,11 @@ vi.mock("./PlexPlayer", () => ({
</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 }),
@@ -100,3 +105,39 @@ describe("the Plex player on a reload", () => {
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();
});
});
+6 -3
View File
@@ -27,6 +27,7 @@ import { useHistorySubview } from "../../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../../lib/plexWatch";
import { usePlayShow } from "./usePlayShow";
import { usePlexPlayerQueue } from "./usePlexPlayerQueue";
import SortControl from "../../components/ui/SortControl";
import FilterSelect from "../../components/ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
@@ -122,6 +123,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
const playerQueue = usePlexPlayerQueue(sub.view);
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
const lang = plexLang(i18n.language);
@@ -429,7 +431,8 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
}
if (sub.view.kind === "player") {
const { queue } = sub.view;
const { playlist } = sub.view;
const queue = playerQueue;
// The fallback is portaled for the same reason the player itself is: it is a fixed
// full-viewport overlay, and this tree renders inside the page scroller.
return (
@@ -442,7 +445,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
fromStart={sub.view.fromStart}
onClose={sub.back}
// The item playing now, with the list it plays from, is what this entry reopens.
onItemChange={(id) => sub.follow({ kind: "player", id, queue })}
onItemChange={(id) => sub.follow({ kind: "player", id, queue, playlist })}
/>
</Suspense>
);
@@ -455,7 +458,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
playlistId={pid}
onBack={sub.back}
onPlay={(itemRk, queue, fromStart) =>
sub.open({ kind: "player", id: itemRk, queue, fromStart })
sub.open({ kind: "player", id: itemRk, queue, playlist: pid, fromStart })
}
onInfoCard={onInfo}
/>
+16 -1
View File
@@ -16,13 +16,19 @@ describe("PLEX_SUB_URL encode", () => {
expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5");
});
// The item playing now — the list it plays from rides in the history entry, not the URL.
// The item playing now — the list it plays from rides in the history entry, a playlist's named.
it("mirrors the player as the item it plays", () => {
expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe(
"player:m9",
);
});
it("names the playlist the player plays through after the item", () => {
expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], playlist: 7 })).toBe(
"player:m9:playlist:7",
);
});
it("does NOT mirror the root Discover landing (encode → null)", () => {
expect(U.encode({ kind: "discover" })).toBeNull();
});
@@ -40,6 +46,7 @@ describe("PLEX_SUB_URL round-trip", () => {
{ kind: "playlist", id: 12 },
{ kind: "collection", id: "c5" },
{ kind: "player", id: "m9" },
{ kind: "player", id: "m9", playlist: 7 },
];
it.each(cases)("encode→decode is identity for %o", (view) => {
const raw = U.encode(view);
@@ -77,6 +84,14 @@ describe("PLEX_SUB_URL decode guards", () => {
expect(U.decode("player:")).toBeNull();
});
it("plays the item alone when the list it names is not a playlist with a valid id", () => {
expect(U.decode("player:m9:playlist:0")).toEqual({ kind: "player", id: "m9" });
expect(U.decode("player:m9:playlist:abc")).toEqual({ kind: "player", id: "m9" });
expect(U.decode("player:m9:season:3")).toEqual({ kind: "player", id: "m9" });
expect(U.decode("player:m9:playlist")).toEqual({ kind: "player", id: "m9" });
expect(U.decode("player:m9:playlist:7:x")).toBeNull();
});
it("rejects an unknown kind or garbage", () => {
expect(U.decode("grid")).toBeNull();
expect(U.decode("bogus")).toBeNull();
+15 -5
View File
@@ -19,7 +19,8 @@ export type Sub =
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
// `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point.
| { kind: "player"; id: string; queue?: string[]; fromStart?: boolean }
// `playlist`: the playlist the queue is, so an address can name the list as well as the item.
| { kind: "player"; id: string; queue?: string[]; playlist?: number; fromStart?: boolean }
| { kind: "info"; id: string }
| { kind: "person"; name: string }
| { kind: "playlist"; id: number }
@@ -29,7 +30,10 @@ export type Sub =
// — the flat browse grid, and the item playing — are bookmarkable. Discover is the ROOT landing, so it
// is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a distinct, shareable
// URL. The player's URL is the item playing NOW (the player rewrites it as it moves on); the list it
// plays from rides in the history entry. The person name is the whole tail after the first ":", so a
// plays from rides in the history entry, and a playlist's is named after it (`player:<id>:playlist:<pid>`)
// so a bookmark rebuilds the queue from the playlist's own order. A season or a show plays through the
// episode's own neighbours, the same order, so neither needs naming; a collection's queue is its sort
// or a shuffle, which no address could give back as it was. The person name is the whole tail after the first ":", so a
// name containing ":" round-trips.
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v",
@@ -54,7 +58,7 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
case "collection":
return `collection:${v.id}`;
case "player":
return `player:${v.id}`;
return ["player", v.id, ...(v.playlist != null ? ["playlist", v.playlist] : [])].join(":");
default:
return null; // discover, the root, is not URL-mirrored
}
@@ -78,8 +82,14 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
return arg ? { kind: "show", id: arg } : null;
case "info":
return arg ? { kind: "info", id: arg } : null;
case "player":
return arg ? { kind: "player", id: arg } : null;
case "player": {
const [id, list, pid, ...rest] = arg.split(":");
if (!id || rest.length > 0) return null;
if (list == null) return { kind: "player", id };
// A list that is not a playlist with a valid id is not this address: the item alone plays.
const playlist = list === "playlist" ? parsePositiveIntId(pid) : null;
return playlist != null ? { kind: "player", id, playlist } : { kind: "player", id };
}
case "person":
return arg ? { kind: "person", name: arg } : null;
case "playlist": {
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { plexLang } from "../../i18n";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import type { Sub } from "./plexSubUrl";
/** The list a Plex player plays through. An in-app entry carries it; a bookmark names only the
* playlist it came from, so the queue is rebuilt from that playlist's own order once it is in — and
* only while the item is still in it (a list without it would leave the item no neighbours at all).
* Until then, or when it cannot be had, the item plays on its own neighbours. */
export function usePlexPlayerQueue(view: Sub): string[] | undefined {
const { i18n } = useTranslation();
const lang = plexLang(i18n.language);
const player = view.kind === "player" ? view : null;
const pid = player && !player.queue ? player.playlist : undefined;
const list = useQuery({
queryKey: qk.plexPlaylist(pid ?? 0, lang),
queryFn: () => api.plexPlaylist(pid!, lang),
enabled: pid != null,
});
if (!player) return undefined;
if (player.queue) return player.queue;
const ids = pid != null ? list.data?.items.map((i) => i.id) : undefined;
return ids?.includes(player.id) ? ids : undefined;
}