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
+3
View File
@@ -314,6 +314,9 @@ export const feedApi = {
duration_seconds: Math.floor(durationSeconds),
}),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
/** One catalog video as the feed lists it (this user's state with it): what a player opened from an
* address starts from. */
videoRow: (id: string): Promise<Video> => req(`/api/videos/${encodeURIComponent(id)}/row`),
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
+21 -5
View File
@@ -36,15 +36,31 @@ describe("channelsParamUpdates", () => {
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
});
it("emits nothing for the all-default state", () => {
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
it("writes the default view in full: this browser remembers one a link must not inherit", () => {
expect(channelsParamUpdates(BASE)).toEqual({
ctab: "subscribed",
csort: "none",
cf: "",
cq: null,
});
});
it("a link to the default view opens it over another remembered one", () => {
const params = applyUpdates(channelsParamUpdates(BASE));
const back = channelsParamsToState(params, {
view: "hidden",
sort: { key: "subscribers", dir: "desc" },
filters: { sync: "fully" },
search: "",
});
expect(back).toEqual(BASE);
});
it("clears every key when passed null (off-manager page)", () => {
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
});
it("emits only non-default keys", () => {
it("writes the view picked", () => {
const u = channelsParamUpdates({
view: "hidden",
sort: { key: "subscribers", dir: "desc" },
@@ -57,8 +73,8 @@ describe("channelsParamUpdates", () => {
expect(u.cq).toBe("tom");
});
it("drops empty filter entries so a cleared funnel emits no cf", () => {
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBeNull();
it("drops empty filter entries so a cleared funnel writes an empty cf", () => {
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBe("");
});
});
+13 -13
View File
@@ -2,10 +2,11 @@
// from the address bar so a manager view is bookmarkable/shareable — the analogue of the feed's
// urlState and Plex's plexUrlState. Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
// emitted, so URLs stay clean. The name search (`cq`) is part of the view like the rest: a bookmark of a
// searched list opens searched.
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
// feed's URL-stripping can't delete them out from under the manager. The tab, sort and filters are
// always written, their defaults too: a page opened without them takes this browser's remembered view,
// which may not be the one bookmarked. The name search (`cq`) is part of the view like the rest (a
// bookmark of a searched list opens searched), but nothing remembers it, so only a term is written.
import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
import { type FilterMap } from "./tableColumns";
import { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
@@ -23,12 +24,11 @@ export const CHANNELS_KEYS = ["ctab", "csort", "cf", "cq"] as const;
const KEYS = CHANNELS_KEYS;
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
// sort here (fallback null), so any sort emits — no default short-circuit needed. The filter map rides
// as one JSON value (lib/filterMapParam).
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`, `none` unsorted) via the shared codec in columnSort. The
// filter map rides as one JSON value (lib/filterMapParam), an empty one as `cf=`.
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
* value, or null to delete it (only the search's, when cleared). Pass `null` to clear
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
* re-mirrors on return). Feed the result into urlWithParams(). */
export function channelsParamUpdates(
@@ -36,9 +36,9 @@ export function channelsParamUpdates(
): Record<string, string | null> {
if (!state) return { ctab: null, csort: null, cf: null, cq: null };
return {
ctab: state.view && state.view !== "subscribed" ? state.view : null,
csort: encodeSortParam(state.sort),
cf: encodeFilterMap(state.filters),
ctab: state.view || "subscribed",
csort: encodeSortParamOrNone(state.sort),
cf: encodeFilterMap(state.filters) ?? "",
cq: state.search.trim() || null,
};
}
@@ -55,10 +55,10 @@ export function channelsParamsToState(
base: ChannelsUrlState,
): ChannelsUrlState {
const tab = params.get("ctab");
const csort = params.get("csort");
const linkedSort = decodeSortParamOrNone(params.get("csort"));
return {
view: tab && VIEWS.includes(tab) ? tab : base.view,
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
sort: linkedSort === undefined ? base.sort : linkedSort,
filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters,
search: params.get("cq") ?? base.search,
};
+16
View File
@@ -53,6 +53,22 @@ export function decodeSortParam(raw: string): SortState {
return parseSortState({ key, dir });
}
// The list's own order (no column sort) as a word of its own, for a page whose sort is remembered (an
// account pref, this browser's view): there the sort is always written, because a page opened without
// one takes the remembered sort, which may not be the one bookmarked.
const UNSORTED = "none";
export function encodeSortParamOrNone(s: SortState): string {
return encodeSortParam(s) ?? UNSORTED;
}
/** The sort an address names; `undefined` when it names none or a malformed one (the remembered one
* applies). */
export function decodeSortParamOrNone(raw: string | null): SortState | undefined {
if (raw == null) return undefined;
return raw === UNSORTED ? null : (decodeSortParam(raw) ?? undefined);
}
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
export function cycleSort(current: SortState, key: string): SortState {
if (!current || current.key !== key) return { key, dir: "asc" };
+19 -15
View File
@@ -1,30 +1,34 @@
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in
// modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the
// sort (`?fsort=`). Here, beside the other pages' key lists, so the page's definition can declare them
// without importing its own folder.
// sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the
// other pages' key lists, so the page's definition can declare them without importing its own folder.
export const FILES_FOLDER_KEY = "folder";
export const FILES_ITEM_KEY = "item";
export const FILES_SEARCH_KEY = "fq";
export const FILES_SORT_KEY = "fsort";
// The file open over the list: in the viewer, or in the player. Each dialog names itself in its own
// history entry (ui/Modal's `url`), so Back lands on the list's address without it.
export const FILES_VIEW_KEY = "fview";
export const FILES_PLAY_KEY = "fplay";
export const FILES_KEYS = [
FILES_FOLDER_KEY,
FILES_ITEM_KEY,
FILES_SEARCH_KEY,
FILES_SORT_KEY,
FILES_VIEW_KEY,
FILES_PLAY_KEY,
] as const;
// The list's own order (no column sort) has a word of its own: the sort is always written, because a
// page opened without one takes the account's remembered sort, which may not be the one bookmarked.
const UNSORTED = "none";
export type FilesOpenFile = { open: "view" | "play"; id: number };
export function encodeFilesSort(sort: SortState): string {
return encodeSortParam(sort) ?? UNSORTED;
}
/** The sort an address names; `undefined` when it names none (the remembered one applies). */
export function decodeFilesSort(raw: string | null): SortState | undefined {
if (raw == null) return undefined;
return raw === UNSORTED ? null : decodeSortParam(raw);
/** The file an address opens over the list (a reload, a bookmark), or null. */
export function openFileFrom(params: URLSearchParams): FilesOpenFile | null {
for (const [open, key] of [
["play", FILES_PLAY_KEY],
["view", FILES_VIEW_KEY],
] as const) {
const id = Number(params.get(key));
if (Number.isInteger(id) && id > 0) return { open, id };
}
return null;
}
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
//
// An overlay that is part of the view (a player) names itself in its own history entry's address, so a
// reload or a bookmark opens it — and the entry under it never carries those keys, so Back lands on the
// page as it was. Driven through jsdom's real history: its back()/forward() land their popstate a
// task later, like a browser's.
import { afterEach, describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useBackToClose } from "./history";
const settle = () =>
act(async () => {
await Promise.resolve();
});
// Step through the history the way the browser does, and wait for its popstate to land.
const step = (go: () => void) =>
act(async () => {
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
go();
await landed;
await Promise.resolve();
});
const search = () => new URLSearchParams(window.location.search);
/** A player-like overlay whose close really unmounts it, naming `url` in its entry. */
function mountPlayer(initial: Record<string, string | null>) {
const holder: { unmount?: () => void } = {};
const r = renderHook(
(url: Record<string, string | null>) => useBackToClose(() => holder.unmount?.(), url),
{ initialProps: initial },
);
holder.unmount = r.unmount;
return r;
}
afterEach(() => {
window.history.replaceState(null, "", "/");
});
describe("an overlay named in its entry's address", () => {
it("is named in its own entry, and Back lands on the address under it without it", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
const depth = window.history.length;
mountPlayer({ watch: "v1", wtab: null });
await settle();
expect(window.history.length).toBe(depth + 1);
expect(search().get("watch")).toBe("v1");
expect(search().get("q")).toBe("music");
await step(() => window.history.back());
expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music");
});
it("follows what it names as it changes", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
const r = mountPlayer({ watch: "v1", wtab: null });
await settle();
r.rerender({ watch: "v2", wtab: "related" });
expect(search().get("watch")).toBe("v2");
expect(search().get("wtab")).toBe("related");
r.rerender({ watch: "v2", wtab: null });
expect(search().has("wtab")).toBe(false);
await step(() => r.unmount());
expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music");
});
it("closed by its own control, leaves the entry under it as it was", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
const r = mountPlayer({ watch: "v1" });
await settle();
await step(() => r.unmount());
expect(window.location.search).toBe("?q=music");
});
it("an entry that still names it after it closed is cleared once it is the current one", async () => {
// A reload under an open player: the entry the new document starts from names the player.
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&watch=v1");
const r = mountPlayer({ watch: "v1" });
await settle();
await step(() => r.unmount());
expect(search().has("watch")).toBe(false);
// Forward onto the closed player's entry: nothing is open there, so neither is it named.
await step(() => window.history.forward());
expect(search().has("watch")).toBe(false);
expect(search().get("q")).toBe("music");
});
});
+58 -6
View File
@@ -6,7 +6,8 @@
// a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first.
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
// its own button pops exactly its entry without tripping the modals underneath.
// its own button pops exactly its entry without tripping the modals underneath. An overlay
// that is part of the view (a player) names itself in its entry's address.
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
@@ -273,8 +274,16 @@ function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
// both undid the navigation AND closed the dialog above it, and the dead Back press after that (the
// last two measured in Chrome). With nothing ever buried, the stack and the history agree by
// construction.
type Overlay = { token: number; cb: () => void };
// `url`: the keys an overlay names in the address of its own entry (and of the entries above it).
type Overlay = { token: number; cb: () => void; url?: OverlayUrl };
type OverlayUrl = Record<string, string | null>;
const overlayStack: Overlay[] = [];
// Every key an overlay has named in this document: an entry carries those of the overlays it stands
// for and none of the rest. The entry under an overlay is never written with its keys, so Back lands
// on an address without it; and one that still names a closed overlay (the entry a reload under an
// open player started from, a Forward onto an entry whose overlay is gone) is cleared once it is
// the current entry.
const overlayUrlKeys = new Set<string>();
let overlayCounter = 0;
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
@@ -298,7 +307,11 @@ function reconcileHistory(): void {
const want = overlayStack.length;
while (historyDepth < want && pendingProgrammatic === 0) {
historyDepth++;
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
window.history.pushState(
{ ...window.history.state, _ov: nextOverlayToken() },
"",
overlayHref(historyDepth),
);
}
while (historyDepth > want) {
historyDepth--;
@@ -308,12 +321,34 @@ function reconcileHistory(): void {
} else {
// 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>;
window.history.replaceState(st, "");
window.history.replaceState(st, "", overlayHref(historyDepth));
}
}
nameOverlaysHere();
syncWaiters.forEach((cb) => cb());
}
// The address of the entry `depth` overlays up: the keys of those overlays, none of the others'.
// Undefined (the address as it is) while no overlay names any.
function overlayHref(depth: number): string | undefined {
if (overlayUrlKeys.size === 0) return undefined;
const updates: OverlayUrl = {};
for (const k of overlayUrlKeys) updates[k] = null;
for (const o of overlayStack.slice(0, depth)) Object.assign(updates, o.url);
return urlWithParams(updates);
}
// Write the current entry's overlay keys, once the history has caught up with the stack (before
// that, the current entry may be one about to be pushed over or stepped back from).
function nameOverlaysHere(): void {
if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return;
const href = overlayHref(historyDepth);
if (href != null && href !== hrefNow())
window.history.replaceState(window.history.state, "", href);
}
const hrefNow = () => window.location.pathname + window.location.search + window.location.hash;
function scheduleReconcile(): void {
if (reconcileScheduled) return;
reconcileScheduled = true;
@@ -328,6 +363,7 @@ function ensureOverlayListener(): void {
pendingProgrammatic--; // our own back(): consume and ignore
// The last one landed: an overlay that opened while it was in flight still owes its entry.
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
else nameOverlaysHere();
return;
}
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost
@@ -449,15 +485,21 @@ export function closeTopOverlayThenRun(close: () => void, run: () => void): void
}
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
export function useBackToClose(onClose: () => void): void {
* `onClose` instead of navigating away. Mount the component only while the overlay is open.
* `url`: what the overlay names in its entry's address (a null value: the key absent), followed as
* it changes; the entry under it never carries those keys. */
export function useBackToClose(onClose: () => void, url?: OverlayUrl): void {
const cb = useEffectEvent(onClose);
const named = useEffectEvent(() => url);
const entryRef = useRef<Overlay | null>(null);
useEffect(() => {
ensureOverlayListener();
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
entryRef.current = entry;
overlayStack.push(entry);
scheduleReconcile();
return () => {
entryRef.current = null;
const idx = overlayStack.indexOf(entry);
// idx === -1 means the shared handler already removed us on a real user Back; either way,
// just re-sync history to the (possibly unchanged) overlay count on the next tick.
@@ -465,6 +507,16 @@ export function useBackToClose(onClose: () => void): void {
scheduleReconcile();
};
}, []);
// After the effect above (declaration order), so the first run names the entry before it is pushed.
const urlDep = url ? JSON.stringify(url) : "";
useEffect(() => {
const entry = entryRef.current;
const next = named();
if (!entry || !next) return;
entry.url = next;
for (const k of Object.keys(next)) overlayUrlKeys.add(k);
nameOverlaysHere();
}, [urlDep]);
}
/** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any
+4
View File
@@ -50,4 +50,8 @@ export type ModuleDef = {
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
// one module and none is a shell key (moduleUrl.contract.test).
urlKeys: readonly string[];
// Its page plays YouTube videos in the player and reopens it from an address that names one (a
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
// the shell's player for such an address: the video alone.
playsVideos?: boolean;
};
+42
View File
@@ -0,0 +1,42 @@
// The YouTube player's address-bar keys. The player opens over any page (the feed, a channel page, Home,
// a playlist, a channel's watched videos), so its keys are the shell's: no module owns them, a page
// switch clears them, and the page under it keeps its own. The player names them in its own history
// 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.
// 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
export 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
export const PLAYER_KEYS = [PLAYER_WATCH_KEY, PLAYER_RELATED_KEY, PLAYER_PANEL_KEY] as const;
export type PlayerPanelTab = "description" | "related";
export type PlayerUrlState = { watch: string; related: string | null; panel: PlayerPanelTab };
/** The player's keys for its entry. The panel's tab is written only when it is not the default: no
* browser remembers it, so a link without one opens the description as this one would. */
export function playerParamUpdates(state: PlayerUrlState): Record<string, string | null> {
return {
[PLAYER_WATCH_KEY]: state.watch,
[PLAYER_RELATED_KEY]: state.related,
[PLAYER_PANEL_KEY]: state.panel === "related" ? "related" : null,
};
}
/** The player an address names, or null when it names none. */
export function playerFrom(params: URLSearchParams): PlayerUrlState | null {
const watch = params.get(PLAYER_WATCH_KEY);
if (!watch) return null;
return {
watch,
related: params.get(PLAYER_RELATED_KEY) || null,
panel: params.get(PLAYER_PANEL_KEY) === "related" ? "related" : "description",
};
}
/** The player the address names now: read by a host as it mounts (a reload, a bookmark), and by the
* player as it opens. No entry but the player's own carries these keys once the history has caught
* up, so a host mounted later (a page switch back) finds none. */
export function linkedPlayer(): PlayerUrlState | null {
return playerFrom(new URLSearchParams(window.location.search));
}
+18 -7
View File
@@ -48,15 +48,26 @@ const BASE: PlexUrlState = {
};
describe("plexParamUpdates", () => {
it("emits nothing for the all-default state", () => {
it("writes the default view in full: this account remembers one a link must not inherit", () => {
expect(plexParamUpdates(BASE)).toEqual({
pscope: null,
psort: null,
pshow: null,
pf: null,
pscope: "both",
psort: "title:asc",
pshow: "all",
pf: "",
});
});
it("a link to the default view opens it over another remembered one", () => {
const params = applyUpdates(plexParamUpdates(BASE));
const back = plexParamsToState(params, {
scope: "movie",
sort: { key: "year", dir: "desc" },
show: "watched",
filters: RICH_FILTERS,
});
expect(back).toEqual(BASE);
});
it("clears every key when passed null (off-plex page)", () => {
expect(plexParamUpdates(null)).toEqual({
pscope: null,
@@ -66,7 +77,7 @@ describe("plexParamUpdates", () => {
});
});
it("emits only non-default keys", () => {
it("writes the view picked", () => {
const u = plexParamUpdates({
scope: "movie",
sort: { key: "year", dir: "desc" },
@@ -76,7 +87,7 @@ describe("plexParamUpdates", () => {
expect(u.pscope).toBe("movie");
expect(u.psort).toBe("year:desc");
expect(u.pshow).toBe("in_progress");
expect(u.pf).toBeNull();
expect(u.pf).toBe("");
});
});
+12 -19
View File
@@ -3,11 +3,11 @@
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare
// `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app
// is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Only non-
// default values are emitted, so URLs stay clean.
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Every key is
// written on the page, its default too: a page opened without one takes this account's remembered view,
// which may not be the one bookmarked.
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
import { PLEX_SORT_FALLBACK } from "./plexSort";
import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
import { parsePlexField, type PlexSearchField } from "./plexSearch";
// The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
@@ -30,25 +30,18 @@ export const PLEX_SUB_KEY = "v";
const SCOPES = ["movie", "show", "both"];
const SHOWS = ["all", "unwatched", "in_progress", "watched"];
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex default (`title:asc`)
// emits nothing.
function encodeSort(s: SortState): string | null {
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
return encodeSortParam(s);
}
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
/** The address-bar delta for the Plex content state: each namespaced key mapped to its value (sort as
* `key:dir`, the shared columnSort codec; an empty filter set as `pf=`). Pass `null` to clear ALL Plex
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
* return). Feed the result into urlWithParams(). */
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
const pf = plexFiltersToParams(state.filters).toString();
return {
pscope: state.scope && state.scope !== "both" ? state.scope : null,
psort: encodeSort(state.sort),
pshow: state.show && state.show !== "all" ? state.show : null,
pf: pf || null,
pscope: state.scope || "both",
psort: encodeSortParamOrNone(state.sort),
pshow: state.show || "all",
pf,
};
}
@@ -62,8 +55,8 @@ export function hasPlexParams(params: URLSearchParams): boolean {
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
const scope = params.get("pscope");
const show = params.get("pshow");
const psort = params.get("psort");
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort;
const linkedSort = decodeSortParamOrNone(params.get("psort"));
const sort = linkedSort === undefined ? base.sort : linkedSort;
return {
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
sort,
+1
View File
@@ -33,6 +33,7 @@ describe("qk — single-id and scalar keys", () => {
it("returns [root, id]", () => {
expect(qk.channel("c1")).toEqual(["channel", "c1"]);
expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]);
expect(qk.videoRow("v1")).toEqual(["video-row", "v1"]);
expect(qk.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]);
expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]);
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
+1
View File
@@ -32,6 +32,7 @@ export const qk = {
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
videoRow: (id: string) => ["video-row", id] as const,
related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
+9 -5
View File
@@ -6,6 +6,7 @@
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import { TAG_CATEGORIES, type FeedFilters } from "./api";
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
import { PLAYER_KEYS } from "./playerUrlState";
export const FEED_KEYS = [
"q",
@@ -33,12 +34,14 @@ export const FEED_KEYS = [
const KEYS = FEED_KEYS;
// The keys the shell itself reads, whatever page is on screen: the page, the channel page and its view
// (it opens over any page, lib/channelPageUrlState), the welcome card's mode and the one-time entry links. No module may declare one of
// these (moduleUrl.contract.test), and a page switch never clears them.
// (it opens over any page, lib/channelPageUrlState), the YouTube player (the same, lib/playerUrlState),
// the welcome card's mode and the one-time entry links. No module may declare one of these
// (moduleUrl.contract.test); a page switch clears only the channel's and the player's.
export const SHELL_KEYS = [
"page",
"chan",
...CHANNEL_PAGE_KEYS,
...PLAYER_KEYS,
"m",
"link",
"oauth",
@@ -231,12 +234,13 @@ export function urlWithParams(updates: Record<string, string | null>): string {
}
/** 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, and none of another channel's
* view (the channel page writes its own, lib/channelPageUrlState). What `openChannel` pushes, and what a
* 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
* it). What `openChannel` pushes, and what a
* channel link points at, so a new tab opens the same view. */
export function channelUrl(id: string): string {
const here = new URLSearchParams(window.location.search);
const viewKeys: readonly string[] = CHANNEL_PAGE_KEYS;
const viewKeys: readonly string[] = [...CHANNEL_PAGE_KEYS, ...PLAYER_KEYS];
const params = new URLSearchParams();
for (const k of SHELL_KEYS) {
const v = k === "chan" ? id : here.get(k);