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
+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