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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user