Files
siftlode/frontend/src/lib/history.ts
T
peter 51193105a7 fix(player): first-class maximise/fullscreen view (UAT round)
UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
  N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
  true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
  fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
  now armed in maximise/fullscreen (it only yields to YouTube's own settings
  menu), so the wheel bubbles to our handler instead of the bare iframe. F also
  offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
  arrow volume and our controls work inside it too — YouTube's own fullscreen
  button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
  (useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
  closing the whole player at once.

Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).
2026-07-26 02:48:46 +02:00

140 lines
6.2 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. Messages thread → list) 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.
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useEffect, useRef, useState } from "react";
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
export function useHistorySubview<T>(root: T): {
view: T;
open: (next: T) => void;
back: () => void;
} {
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
useEffect(() => {
const onPop = () => setView((window.history.state?._sub as T) ?? root);
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
view,
open: (next: T) => {
setView(next);
window.history.pushState({ ...window.history.state, _sub: next }, "");
},
back: () => window.history.back(),
};
}
// --- 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.
type Overlay = { token: number; cb: () => void };
const overlayStack: Overlay[] = [];
let overlayCounter = 0;
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
let historyDepth = 0;
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
let pendingProgrammatic = 0;
let reconcileScheduled = false;
let listenerInstalled = false;
// Push/pop history entries until their count matches the live overlay count. Runs once per tick.
function reconcileHistory(): void {
reconcileScheduled = false;
const want = overlayStack.length;
while (historyDepth < want) {
historyDepth++;
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
}
while (historyDepth > want) {
historyDepth--;
pendingProgrammatic++;
window.history.back();
}
}
function scheduleReconcile(): void {
if (reconcileScheduled) return;
reconcileScheduled = true;
queueMicrotask(reconcileHistory);
}
function ensureOverlayListener(): void {
if (listenerInstalled) return;
listenerInstalled = true;
window.addEventListener("popstate", () => {
if (pendingProgrammatic > 0) {
pendingProgrammatic--; // our own back(): consume and ignore
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.
if (historyDepth > 0) historyDepth--;
const top = overlayStack.pop();
if (top) top.cb();
scheduleReconcile();
});
}
/** 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 {
const cb = useRef(onClose);
cb.current = onClose;
useEffect(() => {
ensureOverlayListener();
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
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();
};
}, []);
}
/** 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 = useRef(onExit);
cb.current = onExit;
useEffect(() => {
if (!active) return;
ensureOverlayListener();
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
const idx = overlayStack.indexOf(entry);
if (idx !== -1) overlayStack.splice(idx, 1);
scheduleReconcile();
};
}, [active]);
}