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:
@@ -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"),
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user