- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
593 lines
30 KiB
TypeScript
593 lines
30 KiB
TypeScript
// In-app browser-history integration so Back/Forward step through a module's sub-views and
|
||
// overlays before leaving the page — not straight back to the previous module.
|
||
//
|
||
// Two primitives share the existing page-history (App stamps `sfPage` in history.state):
|
||
// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside
|
||
// 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. 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";
|
||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||
import { urlWithParams } from "./urlState";
|
||
import { useLatest } from "./useLatest";
|
||
|
||
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
|
||
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
|
||
// remembers scroll offsets per (entry id + view key) so Back restores exactly where the user left
|
||
// an entry, while a forward navigation (a new id) starts at the top. We drive scroll ourselves, so
|
||
// the browser's own window-scroll restoration (which can't see our inner <main> scroller) is off.
|
||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||
window.history.scrollRestoration = "manual";
|
||
}
|
||
let navCounter = 0;
|
||
// sfScroll ids are persisted into window.history.state and survive a full page reload, but this
|
||
// module-level counter resets to 0 on every load. Seed it past the current entry's id so freshly
|
||
// minted ids can't collide with ids already carried by pre-reload back-stack entries (ids are
|
||
// assigned in forward order, so the current entry's id is ≥ every ancestor's) — a collision would
|
||
// otherwise cross-contaminate PageScroller's per-entry scroll offsets after a reload + navigation.
|
||
if (typeof window !== "undefined") {
|
||
const seed = (window.history.state as { sfScroll?: number } | null)?.sfScroll;
|
||
if (typeof seed === "number") navCounter = seed;
|
||
}
|
||
/** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */
|
||
export function nextNavId(): number {
|
||
return ++navCounter;
|
||
}
|
||
/** The current entry's scroll id, assigning one (via replaceState) to entries that predate this — the
|
||
* initial load, or an entry pushed without one — so the id survives Back/Forward. */
|
||
export function ensureNavId(): number {
|
||
const st = (window.history.state ?? {}) as { sfScroll?: number };
|
||
if (typeof st.sfScroll === "number") {
|
||
// Keep the counter ahead of any id we observe (e.g. a Back landed on a higher-id entry after a
|
||
// reload seeded us low) so the next mint never duplicates a live entry's id.
|
||
if (st.sfScroll > navCounter) navCounter = st.sfScroll;
|
||
return st.sfScroll;
|
||
}
|
||
const id = nextNavId();
|
||
window.history.replaceState({ ...st, sfScroll: id }, "");
|
||
return id;
|
||
}
|
||
/** Read the current entry's scroll id without assigning one (0 if none). */
|
||
export function peekNavId(): number {
|
||
const st = window.history.state as { sfScroll?: number } | null;
|
||
return typeof st?.sfScroll === "number" ? st.sfScroll : 0;
|
||
}
|
||
const navListeners = new Set<() => void>();
|
||
/** Fire after a pushState that opens a new view but does NOT re-render PageScroller (a Plex sub-view
|
||
* open): lets the scroller run its restore/reset without waiting on a React render it won't get. */
|
||
export function notifyNav(): void {
|
||
navListeners.forEach((cb) => cb());
|
||
}
|
||
export function subscribeNav(cb: () => void): () => void {
|
||
navListeners.add(cb);
|
||
return () => navListeners.delete(cb);
|
||
}
|
||
|
||
// --- The list item a view was left from (history.state.sfNav) -------------------------------------
|
||
// See lib/listNav "The item a view was left from". Stamped onto the entry being LEFT, right before a
|
||
// push opens a new view over it; read when Back/Forward lands on that entry, and dropped from it there,
|
||
// so a later landing on the same entry — an overlay closing over it — cannot pull focus to an old item.
|
||
// Installed at load, not by the first push: a stamp survives a reload in history.state, and the first
|
||
// Back after one must still find a listener.
|
||
let cancelNavRestore = () => {};
|
||
if (typeof window !== "undefined") {
|
||
window.addEventListener("popstate", (e) => {
|
||
cancelNavRestore();
|
||
const pos = (e.state as { sfNav?: NavPosition } | null)?.sfNav;
|
||
if (!pos) return;
|
||
const { sfNav: _spent, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||
window.history.replaceState(st, "");
|
||
cancelNavRestore = restoreNavPosition(pos);
|
||
});
|
||
}
|
||
|
||
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
|
||
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
|
||
* must spread this, so the new entry does not inherit a position it was never left from — nor an
|
||
* overlay's marks, which belong to the entry being left (an entry a reload under an open dialog
|
||
* started from carries them, and the first overlay over a view pushed from it took that view's entry
|
||
* over for its own). */
|
||
export function rememberNavPosition(): Record<string, unknown> {
|
||
// A restore still waiting for the last landing's lists belongs to the page being left: left
|
||
// running, it would focus "list N, item M" of whatever page this push opens.
|
||
cancelNavRestore();
|
||
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||
const pos = navPosition();
|
||
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
|
||
const { _ov: _overlay, _ovd: _depth, ...view } = st;
|
||
return view;
|
||
}
|
||
|
||
// --- Is the entry a back() lands on still ours? --------------------------------------------
|
||
// Chrome keeps 50 entries a tab. When the list is full a new entry drops the OLDEST "skippable" one —
|
||
// an entry the page left by a pushState without a user gesture (an auto-skip countdown, a card that
|
||
// opened on its own) — not the oldest entry, so it can be this page's own entry under a sub-view.
|
||
// A programmatic back() then leaves the document for whatever older page sits below (measured
|
||
// 2026-10-07: an older entry whose restored Plex player started playing on its own). The Navigation
|
||
// API says which document the entry a back() lands on belongs to — counting the back()s of ours still
|
||
// in flight; a browser without it keeps the old assumption that our entries are all still there.
|
||
interface NavigationLike {
|
||
currentEntry: { index: number } | null;
|
||
entries: () => readonly { sameDocument: boolean }[];
|
||
}
|
||
|
||
/** Would `steps` more steps back (one, a history.back()) still land in this document? */
|
||
export function backStaysInDocument(steps = 1): boolean {
|
||
const nav = (window as { navigation?: NavigationLike }).navigation;
|
||
const at = nav?.currentEntry?.index;
|
||
if (nav == null || at == null) return true;
|
||
return nav.entries()[at - pendingSteps - steps]?.sameDocument === true;
|
||
}
|
||
|
||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||
|
||
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||
export interface SubviewUrl<T> {
|
||
/** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */
|
||
key: string;
|
||
/** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */
|
||
encode: (view: T) => string | null;
|
||
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */
|
||
decode: (raw: string) => T | null;
|
||
/** What the history ENTRY keeps of a view, when that is less than the view opens with: a one-shot
|
||
* flag (the Plex player's `fromStart`) must not replay on a reload or a Forward onto the entry. */
|
||
entry?: (view: T) => T;
|
||
}
|
||
|
||
export function useHistorySubview<T>(
|
||
root: T,
|
||
url?: SubviewUrl<T>,
|
||
): {
|
||
view: T;
|
||
/** `replace` rewrites the CURRENT entry instead of stacking a new one — for a sub-view that is the
|
||
* continuation of the navigation that just arrived here, not a step the user took inside the
|
||
* module. See the `jumpTo` hand-off in PlexBrowse. */
|
||
open: (next: T, opts?: { replace?: boolean }) => void;
|
||
/** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands
|
||
* on says so from now on, without a navigation — the overlays over it stay open. */
|
||
follow: (next: T) => void;
|
||
back: () => void;
|
||
} {
|
||
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
|
||
// ?<key> URL param for a fresh bookmark (whose entry carries the param but no _sub), else root.
|
||
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
|
||
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
|
||
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
|
||
const readView = (): T => {
|
||
const fromState = window.history.state?._sub as T | undefined;
|
||
if (fromState != null) return fromState;
|
||
if (url) {
|
||
const raw = new URLSearchParams(window.location.search).get(url.key);
|
||
const decoded = raw != null ? url.decode(raw) : null;
|
||
if (decoded != null) return decoded;
|
||
}
|
||
return root;
|
||
};
|
||
const [view, setView] = useState<T>(readView);
|
||
// Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its
|
||
// own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten:
|
||
// the view's own entry under the overlay still says where it was until a step lands back on it.
|
||
const followedRef = useRef<{ entry: number; view: T } | null>(null);
|
||
const readLandedView = useEffectEvent((): T => {
|
||
const followed = followedRef.current;
|
||
if (followed == null || peekNavId() !== followed.entry) return readView();
|
||
writeSubEntry(followed.view, url);
|
||
return followed.view;
|
||
});
|
||
useEffect(() => {
|
||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||
const onPop = () => setView(readLandedView());
|
||
window.addEventListener("popstate", onPop);
|
||
return () => window.removeEventListener("popstate", onPop);
|
||
}, []);
|
||
// `open` and `back` are memoised so they are REFERENTIALLY stable, not merely behaviourally so.
|
||
// They used to be plain inline arrows: correct, but a fresh identity every render, which forced
|
||
// every consumer that listens for a sub-view change to disable the dependency check rather than
|
||
// re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a
|
||
// module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only
|
||
// module functions and the setter, so there is nothing left to churn on.
|
||
// Both move the module to another view, so both are navigations: they close the overlays standing
|
||
// over it first (`closeOverlaysThenNavigate`, synchronous when none is open). A step taken under an
|
||
// open overlay landed as a USER Back — the overlay listener spent it closing the top overlay, so
|
||
// leaving the Plex player with its stats readout up only closed the readout — and a push or a
|
||
// replace on top of an overlay's entry is undone when that entry is reclaimed.
|
||
const go = useCallback(
|
||
(next: T, opts?: { replace?: boolean }) => {
|
||
setView(next);
|
||
// A navigation says where the view is now, on the entry it lands on or rewrites.
|
||
followedRef.current = null;
|
||
if (opts?.replace) {
|
||
writeSubEntry(next, url);
|
||
notifyNav();
|
||
return;
|
||
}
|
||
const kept = url?.entry ? url.entry(next) : next;
|
||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
|
||
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
|
||
window.history.pushState(
|
||
{ ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() },
|
||
"",
|
||
href,
|
||
);
|
||
notifyNav();
|
||
},
|
||
[url],
|
||
);
|
||
const open = useCallback(
|
||
(next: T, opts?: { replace?: boolean }) => closeOverlaysThenNavigate(() => go(next, opts)),
|
||
[go],
|
||
);
|
||
// With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes
|
||
// the root view in place — a step up the module, not out of the app.
|
||
const rootRef = useLatest(root);
|
||
const back = useCallback(
|
||
() =>
|
||
closeOverlaysThenNavigate(() => {
|
||
if (backStaysInDocument()) window.history.back();
|
||
else go(rootRef.current, { replace: true });
|
||
}),
|
||
[go, rootRef],
|
||
);
|
||
const follow = useCallback(
|
||
(next: T) => {
|
||
setView(next);
|
||
followedRef.current = { entry: ensureNavId(), view: next };
|
||
writeSubEntry(next, url);
|
||
},
|
||
[url],
|
||
);
|
||
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
|
||
// can now write `[sub]` and mean it, instead of re-running on every parent render.
|
||
return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]);
|
||
}
|
||
|
||
// Rewrite the CURRENT entry to hold `next`. Same entry, so it keeps its sfScroll id: PageScroller keys
|
||
// offsets per (entry, view), and the view key has changed, so the sub-view still opens at the top
|
||
// without minting an id that would orphan the one this entry already carries.
|
||
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
|
||
const kept = url?.entry ? url.entry(next) : next;
|
||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
|
||
}
|
||
|
||
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||
// A SINGLE module-level popstate handler owns the whole overlay stack, and history is kept in
|
||
// sync LAZILY via a coalesced microtask rather than eagerly per mount/unmount. This matters for
|
||
// modal→modal handoffs (e.g. About → Release notes): the leaving modal unmounts and the entering
|
||
// one mounts in the SAME React commit, so within one tick the desired overlay-entry count drops
|
||
// by one then rises by one — a net zero. Reconciling once, after the tick settles, sees that net
|
||
// zero and touches history NOT AT ALL: the new modal simply reuses the old one's entry.
|
||
//
|
||
// The previous eager design pushed on mount and called history.back() on unmount independently.
|
||
// Interleaved during a handoff that produced (a) a flash-and-vanish where the new modal's popstate
|
||
// listener mistook the leaving modal's back() for a genuine user Back and closed itself, and
|
||
// (b) a mangled history where the pointer ended up BEHIND the surviving entry, so a later browser
|
||
// Back walked off the app entirely instead of closing the modal. Coalescing removes both.
|
||
//
|
||
// A navigation never lands UNDER an open overlay: `setPage` and `openChannel` close every overlay
|
||
// first and push only once their entries are back (`closeOverlaysThenNavigate`). An earlier design
|
||
// let the push land above the overlays and "buried" their entries with a counter, and that
|
||
// counter was a whole family of bugs — a burial spent by a different overlay closing first, a
|
||
// navigation that could not report whether it pushed and was assumed to have, one browser Back that
|
||
// 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.
|
||
// `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).
|
||
const nextOverlayToken = () => ++overlayCounter;
|
||
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
||
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;
|
||
// a view pushed from it does not carry its mark (rememberNavPosition). `_ovd` on an overlay's entry is how many overlay
|
||
// entries it stood on, its own included: a reload under two (a dialog, or a maximised player, over
|
||
// the player) leaves the lower one's entry under the one taken over, and closing the taken-over
|
||
// overlay steps back over those too (`deadBelow`) — one Back closes it, as it opened.
|
||
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
|
||
const reloadedDepth: unknown = window.history.state?._ovd;
|
||
let deadBelow = 0;
|
||
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
|
||
// many entries they step over in all (a step over dead entries is one traversal of several).
|
||
let pendingProgrammatic = 0;
|
||
let pendingSteps = 0;
|
||
const pendingStepCounts: number[] = [];
|
||
function stepBack(steps: number): void {
|
||
pendingProgrammatic++;
|
||
pendingSteps += steps;
|
||
pendingStepCounts.push(steps);
|
||
if (steps === 1) window.history.back();
|
||
else window.history.go(-steps);
|
||
}
|
||
function landedStepBack(): void {
|
||
pendingProgrammatic--;
|
||
pendingSteps -= pendingStepCounts.shift() ?? 1;
|
||
}
|
||
// The overlay at the bottom of the stack closing: back over its entry and any dead ones under it.
|
||
function stepsToClose(): number {
|
||
const steps = historyDepth === 0 ? 1 + deadBelow : 1;
|
||
if (historyDepth === 0) deadBelow = 0;
|
||
return steps;
|
||
}
|
||
let reconcileScheduled = false;
|
||
let listenerInstalled = false;
|
||
// Re-checked after every reconcile — see `afterClosing`.
|
||
const syncWaiters = new Set<() => void>();
|
||
|
||
// Push/pop history entries until their count matches the live overlay count. Runs once per tick.
|
||
// A push waits while a back() of ours is still in flight: it would land on the entry just pushed and
|
||
// take it, leaving the books one entry above the history — and the next close then stepped back over
|
||
// the entry BELOW, the player's own (the Plex player's ⋯ → Settings, then a click outside the panel,
|
||
// closed the player). The popstate that lands the last back() reconciles again.
|
||
function reconcileHistory(): void {
|
||
reconcileScheduled = false;
|
||
const want = overlayStack.length;
|
||
while (historyDepth < want && pendingProgrammatic === 0) {
|
||
historyDepth++;
|
||
const here = window.history.state?._ov;
|
||
if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
|
||
reloadedOverlayEntry = null;
|
||
if (typeof reloadedDepth === "number" && reloadedDepth > 1) deadBelow = reloadedDepth - 1;
|
||
continue;
|
||
}
|
||
reloadedOverlayEntry = null;
|
||
window.history.pushState(
|
||
{ ...window.history.state, _ov: nextOverlayToken(), _ovd: historyDepth + deadBelow },
|
||
"",
|
||
);
|
||
}
|
||
while (historyDepth > want) {
|
||
historyDepth--;
|
||
const steps = stepsToClose();
|
||
if (backStaysInDocument(steps)) stepBack(steps);
|
||
else if (steps > 1 && backStaysInDocument()) stepBack(1);
|
||
else {
|
||
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
|
||
const {
|
||
_ov: _drop,
|
||
_ovd: _depth,
|
||
...st
|
||
} = (window.history.state ?? {}) as Record<string, unknown>;
|
||
window.history.replaceState(st, "");
|
||
}
|
||
}
|
||
nameOverlaysHere();
|
||
syncWaiters.forEach((cb) => cb());
|
||
}
|
||
|
||
// 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. Written by `nameOverlaysHere` alone:
|
||
// 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;
|
||
const updates: OverlayUrl = {};
|
||
for (const k of overlayUrlKeys) updates[k] = null;
|
||
for (const o of overlayStack) 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();
|
||
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;
|
||
queueMicrotask(reconcileHistory);
|
||
}
|
||
|
||
function ensureOverlayListener(): void {
|
||
if (listenerInstalled) return;
|
||
listenerInstalled = true;
|
||
window.addEventListener("popstate", () => {
|
||
reloadedOverlayEntry = null;
|
||
if (pendingProgrammatic > 0) {
|
||
landedStepBack(); // 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
|
||
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
|
||
// The last one closing over dead entries (see `deadBelow`) steps over them too.
|
||
if (historyDepth > 0) historyDepth--;
|
||
const dead = historyDepth === 0 ? deadBelow : 0;
|
||
if (historyDepth === 0) deadBelow = 0;
|
||
if (dead > 0 && backStaysInDocument(dead)) stepBack(dead);
|
||
const top = overlayStack.pop();
|
||
if (top) top.cb();
|
||
scheduleReconcile();
|
||
});
|
||
}
|
||
|
||
/** The history holds exactly one entry per live overlay and no programmatic back() is still in
|
||
* flight — so a pushState made now cannot be undone by one. */
|
||
function historyInSync(): boolean {
|
||
return historyDepth === overlayStack.length && pendingProgrammatic === 0 && !reconcileScheduled;
|
||
}
|
||
|
||
// How long an overlay gets to actually close before its command is dropped. Closing is one React
|
||
// render (measured back()→popstate: 1–13 ms in Chrome); nothing in the app refuses a close today,
|
||
// but a future "discard your changes?" guard must not be navigated out from under.
|
||
const CLOSE_TIMEOUT_MS = 1500;
|
||
// An overlay's Back callback is a Back STEP: the contract lets one entry hold several, peeled one per
|
||
// call. Every overlay today closes in one call (the Plex player gives each layer its own entry), but a
|
||
// navigation must not be dropped by one that does not, so it asks again, a render apart (the callback
|
||
// reads what the last one closed), until the entry is gone or the timeout drops the command.
|
||
const RECLOSE_MS = 50;
|
||
|
||
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.
|
||
*
|
||
* The trap: closing unmounts an overlay, whose cleanup reconciles by issuing a programmatic
|
||
* `history.back()`. A pushState made before that lands is undone by it, so a command that navigates
|
||
* silently does nothing but close. PlayerModal hit it first; the command palette the day it was
|
||
* written.
|
||
*
|
||
* It waits for a STATE, not an event. An earlier helper waited for "a popstate" plus a 250 ms
|
||
* fallback — but one reconcile can issue several back()s, an overlay closed in the tick it opened
|
||
* issues none, and the fallback then ran with back()s still in flight. Here the reconcile and every
|
||
* popstate re-check one predicate.
|
||
*
|
||
* `run` goes out on a fresh task, never inside the popstate dispatch that settled it: listeners
|
||
* registered before ours (NavigationProvider's) have read that event already, but ones after it
|
||
* have not, and a page applied from the OLD entry after our push would undo the navigation. */
|
||
function afterClosing(
|
||
targets: readonly Overlay[],
|
||
close: () => void,
|
||
run: () => void,
|
||
reclose = false,
|
||
): void {
|
||
let done = false;
|
||
let queued = false;
|
||
const gone = () => targets.every((o) => !overlayStack.includes(o));
|
||
const ready = () => !done && gone() && historyInSync();
|
||
const stop = () => {
|
||
done = true;
|
||
syncWaiters.delete(check);
|
||
window.removeEventListener("popstate", check);
|
||
clearTimeout(timeout);
|
||
clearInterval(again);
|
||
};
|
||
const again = reclose
|
||
? setInterval(() => {
|
||
if (!done && !gone()) close();
|
||
}, RECLOSE_MS)
|
||
: undefined;
|
||
function check(): void {
|
||
if (queued || !ready()) return;
|
||
queued = true;
|
||
setTimeout(() => {
|
||
queued = false;
|
||
if (!ready()) return;
|
||
stop();
|
||
run();
|
||
}, 0);
|
||
}
|
||
const timeout = setTimeout(() => {
|
||
if (done) return;
|
||
stop();
|
||
// Still mounted: the overlay refused to close, so drop the command rather than navigate under
|
||
// it. Gone but out of sync: a back() never reported back — run late rather than never.
|
||
if (gone()) run();
|
||
}, CLOSE_TIMEOUT_MS);
|
||
syncWaiters.add(check);
|
||
window.addEventListener("popstate", check);
|
||
close();
|
||
check();
|
||
}
|
||
|
||
/** Navigate over whatever overlays are open: close them all (topmost first, the order Back peels
|
||
* them in), then navigate once their entries are back. Synchronous when nothing is open — the
|
||
* ordinary rail click.
|
||
*
|
||
* `setPage` and `openChannel` call it themselves, so no call site can forget it: a global shortcut,
|
||
* a toast's "View" clicked inside the player, a palette row, the player's channel link. Closing is
|
||
* what a page change already did to the module-owned overlays (it unmounts them); for the ones above
|
||
* the page column (About, the cheat sheet, the palette) it means the destination is what the user
|
||
* sees, not a dialog still standing over it. A navigation that turns out to go nowhere — Shift+W on
|
||
* Home with the player open — still closes, so the key always ends with the answer on screen (owner
|
||
* decision 2026-09-09). */
|
||
export function closeOverlaysThenNavigate(navigate: () => void): void {
|
||
if (overlayStack.length === 0 && historyInSync()) {
|
||
navigate();
|
||
return;
|
||
}
|
||
const targets = [...overlayStack];
|
||
const close = () =>
|
||
[...targets]
|
||
.reverse()
|
||
.filter((o) => overlayStack.includes(o))
|
||
.forEach((o) => o.cb());
|
||
afterClosing(targets, close, navigate, true);
|
||
}
|
||
|
||
/** Close the overlay a control lives in, then run its command once that overlay's entry is back —
|
||
* the command palette's rows. Overlays underneath are left alone: a theme picked over the player
|
||
* must not close the player, and a row that navigates closes it through `setPage` anyway. */
|
||
export function closeTopOverlayThenRun(close: () => void, run: () => void): void {
|
||
const top = overlayStack[overlayStack.length - 1];
|
||
afterClosing(top ? [top] : [], close, run);
|
||
}
|
||
|
||
/** 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.
|
||
* `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.
|
||
if (idx !== -1) overlayStack.splice(idx, 1);
|
||
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
|
||
* other active `useBackToExit` (LIFO) so nested views peel off one Back at a time — e.g. a player's
|
||
* fullscreen sits above its maximise, which sits above the player itself: Back exits fullscreen,
|
||
* then maximise, then closes the player. Leaving a layer by its own control (button/Esc) flips
|
||
* `active` false, whose cleanup drops the entry and its history state on the next tick — exactly the
|
||
* useBackToClose lifecycle, just gated on a flag. `onExit` runs on a genuine Back while active. */
|
||
export function useBackToExit(active: boolean, onExit: () => void): void {
|
||
const cb = useEffectEvent(onExit);
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
ensureOverlayListener();
|
||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||
overlayStack.push(entry);
|
||
scheduleReconcile();
|
||
return () => {
|
||
const idx = overlayStack.indexOf(entry);
|
||
if (idx !== -1) overlayStack.splice(idx, 1);
|
||
scheduleReconcile();
|
||
};
|
||
}, [active]);
|
||
}
|