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
@@ -231,7 +231,7 @@ describe("NavigationProvider · a link carrying another page's keys", () => {
it("opens a channel link with only the channel page's keys, even over the page they belong to", () => { it("opens a channel link with only the channel page's keys, even over the page they belong to", () => {
window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns"); window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns");
render(); render();
expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns"); expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns&page=feed");
}); });
}); });
@@ -242,10 +242,10 @@ describe("NavigationProvider · the channel page's address", () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest"); window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest");
const { result } = render(); const { result } = render();
act(() => result.current.openChannel("UC1", "One")); act(() => result.current.openChannel("UC1", "One"));
expect(window.location.search).toBe("?chan=UC1"); expect(window.location.search).toBe("?page=feed&chan=UC1");
act(() => window.history.back()); act(() => window.history.back());
await vi.waitFor(() => expect(result.current.channelView).toBeNull()); await vi.waitFor(() => expect(result.current.channelView).toBeNull());
expect(window.location.search).toBe("?q=music&sort=oldest"); expect(window.location.search).toBe("?q=music&sort=oldest&page=feed");
}); });
it("says no page owns the address while the channel stands over it", () => { it("says no page owns the address while the channel stands over it", () => {
@@ -277,7 +277,7 @@ describe("NavigationProvider · the channel page's address", () => {
const { result } = render(); const { result } = render();
stubNavigation([false]); stubNavigation([false]);
act(() => result.current.closeChannel()); act(() => result.current.closeChannel());
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
}); });
}); });
@@ -293,7 +293,7 @@ describe("NavigationProvider · the player's address", () => {
it("a channel link names no player: opening the channel closes it", () => { it("a channel link names no player: opening the channel closes it", () => {
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related"); window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
render(); render();
expect(channelUrl("UC1")).toBe("/?chan=UC1"); expect(channelUrl("UC1")).toBe("/?page=feed&chan=UC1");
}); });
}); });
@@ -310,7 +310,7 @@ describe("NavigationProvider · Back", () => {
expect(result.current.page).toBe("feed"); expect(result.current.page).toBe("feed");
// …and the entry says so too, so the address bar and a later Feed click agree with the screen. // …and the entry says so too, so the address bar and a later Feed click agree with the screen.
expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.location.search).toBe("?keep=1"); expect(window.location.search).toBe("?page=feed&keep=1");
}); });
it("lands where a retired page's content went, and the entry names that page", () => { it("lands where a retired page's content went, and the entry names that page", () => {
@@ -427,7 +427,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
window.history.replaceState(null, "", "/?page=scheduler&keep=1"); window.history.replaceState(null, "", "/?page=scheduler&keep=1");
const { result } = render(); const { result } = render();
expect(result.current.page).toBe("feed"); expect(result.current.page).toBe("feed");
expect(window.location.search).toBe("?keep=1"); expect(window.location.search).toBe("?page=feed&keep=1");
expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
}); });
@@ -463,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
qc.setQueryData(qk.me(), member); qc.setQueryData(qk.me(), member);
}); });
await settle(); await settle();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed"); expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => { act(() => {
@@ -483,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
}); });
await settle(); await settle();
expect(result.current.channelView).toBeNull(); expect(result.current.channelView).toBeNull();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
expect(window.history.state?._chan).toBeUndefined(); expect(window.history.state?._chan).toBeUndefined();
}); });
}); });
@@ -44,12 +44,13 @@ import {
// //
// The contexts, their value types and the hooks live in navigationContext.ts. // The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit), no channel or player over it, and every /** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or
* module's own keys cleared — a page switch resets to that module's root, and its store mirrors what * the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or
* it holds back in. */ * player over it, and every module's own keys cleared — a page switch resets to that module's root,
* and its store mirrors what it holds back in. */
function pageRootParams(page: Page): Record<string, string | null> { function pageRootParams(page: Page): Record<string, string | null> {
return { return {
page: page === "feed" ? null : page, page,
...closedChannelParams(), ...closedChannelParams(),
...nulls(PLAYER_KEYS), ...nulls(PLAYER_KEYS),
...nulls(moduleUrlKeys()), ...nulls(moduleUrlKeys()),
@@ -126,7 +127,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setAccountRaw(PAGE_KEY, next); setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// root, and the address bar reflects the page (feed is implicit) while clearing `chan` and // root, and the address bar reflects the page (the feed too) while clearing `chan` and
// every module's own keys (pageRootParams) — a page switch resets to that module's root, and // every module's own keys (pageRootParams) — a page switch resets to that module's root, and
// each module's store mirrors its state back in on its page. The item it was opened from is // each module's store mirrors its state back in on its page. The item it was opened from is
// still focused: the state setters above have not re-rendered anything yet. // still focused: the state setters above have not re-rendered anything yet.
@@ -247,7 +248,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5. // are always kept so a channel page survives F5.
const st = window.history.state || {}; const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the // Mirror the resolved initial page to the address bar (the feed too) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read. // ?chan / ?v / ?pl / filter params the loaders already read.
// A retired page's link — or the remembered last page, when that is what opened — also opens its // A retired page's link — or the remembered last page, when that is what opened — also opens its
@@ -261,7 +262,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const url = urlWithParams({ const url = urlWithParams({
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)), ...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage, page: mountPage,
}); });
if (st._yt && st._ytScrape) { if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url); window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
@@ -282,7 +283,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.replaceState( window.history.replaceState(
{ ...window.history.state, sfPage: p }, { ...window.history.state, sfPage: p },
"", "",
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }), urlWithParams({ ...retiredParams(sf), page: p }),
); );
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
@@ -165,6 +165,22 @@ describe("the player's address", () => {
expect(window.location.search).toBe(""); 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 () => { it("wide: a link naming another item brings none of its view", async () => {
wideWindow(); wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related"); window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer"; import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video; const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -48,13 +49,14 @@ describe("useOpenLinkedPlayer", () => {
const open = vi.fn(); const open = vi.fn();
const list = [vid("A"), vid("B")]; const list = [vid("A"), vid("B")];
const { rerender } = renderHook( const { rerender } = renderHook(
({ ready }) => useOpenLinkedPlayer(ready, (id) => list.find((v) => v.id === id), open), ({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open),
{ wrapper, initialProps: { ready: false } }, { wrapper, initialProps: { ready: false, rows: list } },
); );
expect(open).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
rerender({ ready: true }); rerender({ ready: true, rows: list });
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true)); 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(open).toHaveBeenCalledTimes(1);
expect(row).not.toHaveBeenCalled(); expect(row).not.toHaveBeenCalled();
}); });
@@ -77,6 +79,8 @@ describe("useOpenLinkedPlayer", () => {
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"), expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
); );
expect(open).not.toHaveBeenCalled(); 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 () => { 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(); 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 () => { it("leaves it to a page that hosts its own", async () => {
window.history.replaceState(null, "", "/?watch=Z"); window.history.replaceState(null, "", "/?watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("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 { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; 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"; import { notifyActionError } from "../../lib/actionErrors";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that /** 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, retry: false,
}); });
const openIt = useEffectEvent(open); 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(() => { 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); if (listed) openIt(listed, true);
else if (row.data) openIt(row.data, false); else if (row.data) openIt(row.data, false);
else if (row.isError) failed(row.error); else if (row.isError) failed(row.error);
else return; else return;
done.current = true; done.current = true;
}, [listed, row.data, row.isError, row.error]); }, [ready, listed, row.data, row.isError, row.error]);
} }
+20
View File
@@ -0,0 +1,20 @@
// The file an address names open on the Files page: the player's key wins over the viewer's, and only a
// real row id opens anything — a fractional, zero, negative or garbled one would look up a row that
// cannot exist and say the file is gone.
import { describe, expect, it } from "vitest";
import { openFileFrom } from "./filesUrlState";
const from = (q: string) => openFileFrom(new URLSearchParams(q));
describe("openFileFrom", () => {
it("reads the viewer's and the player's file", () => {
expect(from("fview=3")).toEqual({ open: "view", id: 3 });
expect(from("fplay=4")).toEqual({ open: "play", id: 4 });
expect(from("fview=3&fplay=4")).toEqual({ open: "play", id: 4 });
});
it("opens nothing for an id that is not a positive integer", () => {
for (const raw of ["1.5", "0", "-2", "abc", ""]) expect(from(`fview=${raw}`)).toBeNull();
expect(from("q=x")).toBeNull();
});
});
@@ -46,7 +46,12 @@ describe("an overlay named in its entry's address", () => {
expect(window.history.length).toBe(depth + 1); expect(window.history.length).toBe(depth + 1);
expect(search().get("watch")).toBe("v1"); expect(search().get("watch")).toBe("v1");
expect(search().get("q")).toBe("music"); expect(search().get("q")).toBe("music");
// What a page reads as Back lands (a host mounting there reopens a player its address names):
// the entry under the player never carried it, so there is nothing to clear first.
let landedOn = "";
window.addEventListener("popstate", () => (landedOn = window.location.search), { once: true });
await step(() => window.history.back()); await step(() => window.history.back());
expect(landedOn).toBe("?q=music");
expect(search().has("watch")).toBe(false); expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music"); expect(search().get("q")).toBe("music");
}); });
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
//
// A reload under an open overlay (a player the address names) loads the document on that overlay's
// entry, and the page opens the overlay again. It takes that entry over: pushed over it instead, Back
// would close the player onto the same page once more — one dead step for every reload. Its own file:
// the entry is read as the module loads, so it is set up before the import.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
const search = () => new URLSearchParams(window.location.search);
describe("an overlay reopened on the entry a reload started from", () => {
it("takes that entry over, and Back lands on the page under it", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
window.history.pushState({ sfPage: "feed", _ov: 3 }, "", "/?q=music&watch=v1");
const depth = window.history.length;
const { useBackToClose } = await import("./history");
const holder: { unmount?: () => void } = {};
const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" }));
holder.unmount = r.unmount;
await act(async () => {
await Promise.resolve();
});
expect(window.history.length).toBe(depth);
expect(search().get("watch")).toBe("v1");
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
window.history.back();
await landed;
await Promise.resolve();
});
expect(window.location.search).toBe("?q=music");
});
});
+19 -11
View File
@@ -290,6 +290,10 @@ let overlayCounter = 0;
const nextOverlayToken = () => ++overlayCounter; const nextOverlayToken = () => ++overlayCounter;
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length. // Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
let historyDepth = 0; let historyDepth = 0;
// The entry this document was loaded on, when it was an overlay's (a reload under an open player):
// the first overlay to open takes it over rather than pushing another over it — pushed, Back would
// close the player onto that same page again, one dead step per reload. Until the first push or Back.
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
// Programmatic history.back() calls still awaiting their popstate — each must be ignored. // Programmatic history.back() calls still awaiting their popstate — each must be ignored.
let pendingProgrammatic = 0; let pendingProgrammatic = 0;
let reconcileScheduled = false; let reconcileScheduled = false;
@@ -307,11 +311,13 @@ function reconcileHistory(): void {
const want = overlayStack.length; const want = overlayStack.length;
while (historyDepth < want && pendingProgrammatic === 0) { while (historyDepth < want && pendingProgrammatic === 0) {
historyDepth++; historyDepth++;
window.history.pushState( const here = window.history.state?._ov;
{ ...window.history.state, _ov: nextOverlayToken() }, if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
"", reloadedOverlayEntry = null;
overlayHref(historyDepth), continue;
); }
reloadedOverlayEntry = null;
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
} }
while (historyDepth > want) { while (historyDepth > want) {
historyDepth--; historyDepth--;
@@ -321,20 +327,21 @@ function reconcileHistory(): void {
} else { } else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked. // Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>; const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "", overlayHref(historyDepth)); window.history.replaceState(st, "");
} }
} }
nameOverlaysHere(); nameOverlaysHere();
syncWaiters.forEach((cb) => cb()); syncWaiters.forEach((cb) => cb());
} }
// The address of the entry `depth` overlays up: the keys of those overlays, none of the others'. // The address of the entry the open overlays stand on: their keys, every other overlay's cleared.
// Undefined (the address as it is) while no overlay names any. // Undefined (the address as it is) while no overlay names any. Written by `nameOverlaysHere` alone:
function overlayHref(depth: number): string | undefined { // a pushed entry starts as a copy of the one under it and is named once it is the current one.
function overlayHref(): string | undefined {
if (overlayUrlKeys.size === 0) return undefined; if (overlayUrlKeys.size === 0) return undefined;
const updates: OverlayUrl = {}; const updates: OverlayUrl = {};
for (const k of overlayUrlKeys) updates[k] = null; for (const k of overlayUrlKeys) updates[k] = null;
for (const o of overlayStack.slice(0, depth)) Object.assign(updates, o.url); for (const o of overlayStack) Object.assign(updates, o.url);
return urlWithParams(updates); return urlWithParams(updates);
} }
@@ -342,7 +349,7 @@ function overlayHref(depth: number): string | undefined {
// that, the current entry may be one about to be pushed over or stepped back from). // that, the current entry may be one about to be pushed over or stepped back from).
function nameOverlaysHere(): void { function nameOverlaysHere(): void {
if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return; if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return;
const href = overlayHref(historyDepth); const href = overlayHref();
if (href != null && href !== hrefNow()) if (href != null && href !== hrefNow())
window.history.replaceState(window.history.state, "", href); window.history.replaceState(window.history.state, "", href);
} }
@@ -359,6 +366,7 @@ function ensureOverlayListener(): void {
if (listenerInstalled) return; if (listenerInstalled) return;
listenerInstalled = true; listenerInstalled = true;
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
reloadedOverlayEntry = null;
if (pendingProgrammatic > 0) { if (pendingProgrammatic > 0) {
pendingProgrammatic--; // our own back(): consume and ignore pendingProgrammatic--; // our own back(): consume and ignore
// The last one landed: an overlay that opened while it was in flight still owes its entry. // The last one landed: an overlay that opened while it was in flight still owes its entry.
+4 -4
View File
@@ -4,9 +4,9 @@
// entry (lib/history's `useBackToClose`), so Back lands on the page as it was, without the player. // entry (lib/history's `useBackToClose`), so Back lands on the page as it was, without the player.
// The list it plays from is the page's: the feed's filters, the open playlist (`pl`) — already named. // The list it plays from is the page's: the feed's filters, the open playlist (`pl`) — already named.
// No position: the player resumes from the server's (`video_states`), which is where it was left. // No position: the player resumes from the server's (`video_states`), which is where it was left.
export const PLAYER_WATCH_KEY = "watch"; // the item of the list playing now const PLAYER_WATCH_KEY = "watch"; // the item of the list playing now
export const PLAYER_RELATED_KEY = "wrel"; // a related video played in place over it const PLAYER_RELATED_KEY = "wrel"; // a related video played in place over it
export const PLAYER_PANEL_KEY = "wtab"; // the wide panel's tab, when it is not the description const PLAYER_PANEL_KEY = "wtab"; // the wide panel's tab, when it is not the description
export const PLAYER_KEYS = [PLAYER_WATCH_KEY, PLAYER_RELATED_KEY, PLAYER_PANEL_KEY] as const; export const PLAYER_KEYS = [PLAYER_WATCH_KEY, PLAYER_RELATED_KEY, PLAYER_PANEL_KEY] as const;
export type PlayerPanelTab = "description" | "related"; export type PlayerPanelTab = "description" | "related";
@@ -24,7 +24,7 @@ export function playerParamUpdates(state: PlayerUrlState): Record<string, string
} }
/** The player an address names, or null when it names none. */ /** The player an address names, or null when it names none. */
export function playerFrom(params: URLSearchParams): PlayerUrlState | null { function playerFrom(params: URLSearchParams): PlayerUrlState | null {
const watch = params.get(PLAYER_WATCH_KEY); const watch = params.get(PLAYER_WATCH_KEY);
if (!watch) return null; if (!watch) return null;
return { return {
+10
View File
@@ -57,6 +57,16 @@ describe("plexParamUpdates", () => {
}); });
}); });
it("writes an unsorted view as such, so a link to it is not sorted by the remembered sort", () => {
const unsorted = { ...BASE, sort: null };
expect(plexParamUpdates(unsorted).psort).toBe("none");
const back = plexParamsToState(applyUpdates(plexParamUpdates(unsorted)), {
...BASE,
sort: { key: "year", dir: "desc" },
});
expect(back?.sort).toBeNull();
});
it("a link to the default view opens it over another remembered one", () => { it("a link to the default view opens it over another remembered one", () => {
const params = applyUpdates(plexParamUpdates(BASE)); const params = applyUpdates(plexParamUpdates(BASE));
const back = plexParamsToState(params, { const back = plexParamsToState(params, {
+7
View File
@@ -233,6 +233,13 @@ export function urlWithParams(updates: Record<string, string | null>): string {
return hrefHere(params); return hrefHere(params);
} }
/** Take `keys` off the current entry's address: what a link named and the page could not show (a file
* or a video that is gone, said once) must not stay to be said again on every reload, or shared. */
export function dropFromAddress(keys: readonly string[]): void {
const updates = Object.fromEntries(keys.map((k) => [k, null]));
window.history.replaceState(window.history.state, "", urlWithParams(updates));
}
/** The channel page's address, opened over the page on screen: the shell's own keys (the page, an /** The channel page's address, opened over the page on screen: the shell's own keys (the page, an
* entry link not yet read) and the channel — none of the page's keys, none of another channel's view * entry link not yet read) and the channel — none of the page's keys, none of another channel's view
* (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes * (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes
+21
View File
@@ -161,3 +161,24 @@ describe("module URL keys", () => {
for (const key of ["pl", "page", "rjob", "link"]) expect(seen).toContain(key); for (const key of ["pl", "page", "rjob", "link"]) expect(seen).toContain(key);
}); });
}); });
// A page that opens the player an address names itself (as its queue) says so in its definition, and
// the shell's player stands back over it: one that did not would get the shell's too — two players.
describe("the player an address names", () => {
const opensIt = (page: Page) =>
sources(join(SRC, "modules", page)).some((f) =>
readFileSync(f, "utf8").includes("useOpenLinkedPlayer("),
);
it.each([...REGISTERED_PAGES])("is opened by %s exactly when it says it plays videos", (page) => {
expect(!!moduleDef(page).playsVideos).toBe(opensIt(page));
});
it("is left to the shell on every other page", () => {
const app = readFileSync(join(SRC, "shell", "App.tsx"), "utf8");
expect(app).toContain(
"<LinkedPlayer hosted={!!channelView || !!moduleDef(page).playsVideos} />",
);
expect(REGISTERED_PAGES.filter(opensIt).sort()).toEqual(["feed", "home", "playlists"]);
});
});
@@ -200,8 +200,11 @@ function mount({
describe("Feed — a player the address names", () => { describe("Feed — a player the address names", () => {
it("reopens on its video, the list as its queue", async () => { it("reopens on its video, the list as its queue", async () => {
window.history.replaceState(null, "", "/?watch=v1"); 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(); mount();
expect(await screen.findByText("playing v1 in v1")).toBeTruthy(); 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 () => { it("plays a video the loaded list does not hold alone, from the catalog", async () => {
@@ -181,6 +181,7 @@ afterEach(() => {
mocks.titleArgs = []; mocks.titleArgs = [];
mocks.wideLoading = false; mocks.wideLoading = false;
mocks.wideFailed = false; mocks.wideFailed = false;
mocks.searched = [];
mocks.notify.mockClear(); mocks.notify.mockClear();
resetQueueWatch(); resetQueueWatch();
}); });
@@ -776,6 +777,24 @@ describe("Files · a file the address names", () => {
expect(await screen.findByText("playing 1")).toBeTruthy(); 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 () => { it("says so when the list no longer holds it", async () => {
window.history.replaceState(null, "", "/?page=files&fview=99"); window.history.replaceState(null, "", "/?page=files&fview=99");
mocks.files = [doc(2)]; mocks.files = [doc(2)];
@@ -787,6 +806,8 @@ describe("Files · a file the address names", () => {
), ),
); );
expect(screen.queryByRole("dialog")).toBeNull(); 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 { useCardPager } from "../../lib/useCardPager";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { useUrlMirror } from "../../lib/useUrlMirror"; 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 { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { JobThumb } from "../../features/downloads/downloadRow"; import { JobThumb } from "../../features/downloads/downloadRow";
@@ -280,14 +286,16 @@ export default function Files() {
const [viewing, setViewing] = useState<number | null>(null); 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 // 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 // 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 [linkedFile] = useState(() => openFileFrom(new URLSearchParams(window.location.search)));
const linkedDone = useRef(linkedFile == null); const linkedDone = useRef(linkedFile == null);
const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined; const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined;
const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined; const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined;
const openLinked = useEffectEvent((file: LibraryFile | undefined) => { const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
if (!file) notify({ level: "warning", message: t("files.linkedGone") }); if (!file) {
else if (linkedFile?.open === "play") play(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); else setViewing(file.id);
}); });
useEffect(() => { useEffect(() => {
@@ -235,3 +235,13 @@ describe("LibraryPlayer", () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() }); 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", () => { describe("Playlists — a player the address names", () => {
it("reopens on its video, the playlist's view as the queue", async () => { it("reopens on its video, the playlist's view as the queue", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=B"); 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)] }); mount({ 1: [vid("A", 0), vid("B", 0)] });
await waitFor(() => expect(h.player?.video.id).toBe("B")); await waitFor(() => expect(h.player?.video.id).toBe("B"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["A", "B"]); expect(h.player!.queue?.map((v) => v.id)).toEqual(["A", "B"]);
expect(h.player!.startIndex).toBe(1); expect(h.player!.startIndex).toBe(1);
expect(h.player!.onRemoveFromList).toBeDefined(); 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 () => { 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> </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) => ({ vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()), ...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), 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(); 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 { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../../lib/plexWatch"; import { toggleWatchState } from "../../lib/plexWatch";
import { usePlayShow } from "./usePlayShow"; import { usePlayShow } from "./usePlayShow";
import { usePlexPlayerQueue } from "./usePlexPlayerQueue";
import SortControl from "../../components/ui/SortControl"; import SortControl from "../../components/ui/SortControl";
import FilterSelect from "../../components/ui/FilterSelect"; import FilterSelect from "../../components/ui/FilterSelect";
import PlexDiscover from "./PlexDiscover"; 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 { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions(); const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350); 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 // 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. // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
const lang = plexLang(i18n.language); const lang = plexLang(i18n.language);
@@ -429,7 +431,8 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
} }
if (sub.view.kind === "player") { 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 // 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. // full-viewport overlay, and this tree renders inside the page scroller.
return ( return (
@@ -442,7 +445,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
fromStart={sub.view.fromStart} fromStart={sub.view.fromStart}
onClose={sub.back} onClose={sub.back}
// The item playing now, with the list it plays from, is what this entry reopens. // 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> </Suspense>
); );
@@ -455,7 +458,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
playlistId={pid} playlistId={pid}
onBack={sub.back} onBack={sub.back}
onPlay={(itemRk, queue, fromStart) => onPlay={(itemRk, queue, fromStart) =>
sub.open({ kind: "player", id: itemRk, queue, fromStart }) sub.open({ kind: "player", id: itemRk, queue, playlist: pid, fromStart })
} }
onInfoCard={onInfo} 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"); 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", () => { it("mirrors the player as the item it plays", () => {
expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe( expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe(
"player:m9", "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)", () => { it("does NOT mirror the root Discover landing (encode → null)", () => {
expect(U.encode({ kind: "discover" })).toBeNull(); expect(U.encode({ kind: "discover" })).toBeNull();
}); });
@@ -40,6 +46,7 @@ describe("PLEX_SUB_URL round-trip", () => {
{ kind: "playlist", id: 12 }, { kind: "playlist", id: 12 },
{ kind: "collection", id: "c5" }, { kind: "collection", id: "c5" },
{ kind: "player", id: "m9" }, { kind: "player", id: "m9" },
{ kind: "player", id: "m9", playlist: 7 },
]; ];
it.each(cases)("encode→decode is identity for %o", (view) => { it.each(cases)("encode→decode is identity for %o", (view) => {
const raw = U.encode(view); const raw = U.encode(view);
@@ -77,6 +84,14 @@ describe("PLEX_SUB_URL decode guards", () => {
expect(U.decode("player:")).toBeNull(); 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", () => { it("rejects an unknown kind or garbage", () => {
expect(U.decode("grid")).toBeNull(); expect(U.decode("grid")).toBeNull();
expect(U.decode("bogus")).toBeNull(); expect(U.decode("bogus")).toBeNull();
+15 -5
View File
@@ -19,7 +19,8 @@ export type Sub =
| { kind: "show"; id: string } | { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string } | { kind: "season"; showId: string; seasonId: string }
// `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point. // `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: "info"; id: string }
| { kind: "person"; name: string } | { kind: "person"; name: string }
| { kind: "playlist"; id: number } | { 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 // — 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 // 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 // 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. // name containing ":" round-trips.
export const PLEX_SUB_URL: SubviewUrl<Sub> = { export const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v", key: "v",
@@ -54,7 +58,7 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
case "collection": case "collection":
return `collection:${v.id}`; return `collection:${v.id}`;
case "player": case "player":
return `player:${v.id}`; return ["player", v.id, ...(v.playlist != null ? ["playlist", v.playlist] : [])].join(":");
default: default:
return null; // discover, the root, is not URL-mirrored 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; return arg ? { kind: "show", id: arg } : null;
case "info": case "info":
return arg ? { kind: "info", id: arg } : null; return arg ? { kind: "info", id: arg } : null;
case "player": case "player": {
return arg ? { kind: "player", id: arg } : null; 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": case "person":
return arg ? { kind: "person", name: arg } : null; return arg ? { kind: "person", name: arg } : null;
case "playlist": { 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;
}