Files
siftlode/frontend/src/lib/fullscreen.ts
T
peter d25a132495 fix(ui): a dialog whose own video goes fullscreen no longer crashes the app
The portal host followed the fullscreen element into it. When that element is the dialog's own
content (the library player's <video>, by its f key or its own button), the move is into the host's
own descendant, which the DOM refuses: HierarchyRequestError in a layout effect, and the crash
screen replaced the app. The host now stays where it is then — the content already shows.
2026-10-07 23:16:25 +02:00

112 lines
5.4 KiB
TypeScript

import {
useEffect,
useInsertionEffect,
useLayoutEffect,
useState,
useSyncExternalStore,
} from "react";
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
// otherwise close the whole player instead of just leaving fullscreen).
//
// Both video surfaces (PlayerModal's YouTube embed, PlexPlayer's <video>) go through here, so the
// prefix handling lives in ONE place — the previous shape had it in neither, then in one.
type WebkitDocument = Document & {
webkitFullscreenElement?: Element | null;
webkitExitFullscreen?: () => void;
};
type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
/** The element currently holding the fullscreen lock, or null. */
export function fullscreenElement(): Element | null {
const d = document as WebkitDocument;
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
export function requestFullscreen(el: HTMLElement): void {
const e = el as WebkitElement;
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
else e.webkitRequestFullscreen?.();
}
/** Leave fullscreen, whoever holds it. */
export function exitFullscreen(): void {
const d = document as WebkitDocument;
if (d.exitFullscreen) void d.exitFullscreen().catch(() => {});
else d.webkitExitFullscreen?.();
}
/** Subscribe to fullscreen transitions; returns the unsubscribe. Registers BOTH spellings — a
* browser that supports both never fires both for the same transition, so the handler still runs
* once. */
export function onFullscreenChange(handler: () => void): () => void {
document.addEventListener("fullscreenchange", handler);
document.addEventListener("webkitfullscreenchange", handler);
return () => {
document.removeEventListener("fullscreenchange", handler);
document.removeEventListener("webkitfullscreenchange", handler);
};
}
// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across
// calls — a valid useSyncExternalStore snapshot without any caching of our own.
const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
const fsServerSnapshot = () => null;
/** The fullscreen element as reactive state, for code that must RENDER differently while something
* holds the lock — today only `useFullscreenPortalHost` below, which has to portal INTO that element
* (a node portaled to <body> is painted underneath the fullscreen element, i.e. invisible).
* Components that merely react to the transition should keep using `onFullscreenChange` directly. */
function useFullscreenElement(): HTMLElement | null {
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
}
/** ONE container div for an overlay's whole life, re-parented between `<body>` and the fullscreen
* element instead of handed to `createPortal` as a changing container: React tears a portal down
* and builds it again when its container changes, and the overlay's contents go with it (a
* half-filled form reset to its defaults the moment the viewer left true fullscreen).
*
* Two rules this owns, which the two hand-written copies it replaced disagreed about:
*
* 1. **It is attached BEFORE the portal's children commit.** `useInsertionEffect` runs in the
* mutation phase, and React handles `autoFocus` in `commitMount` during the LAYOUT phase — one
* whole phase later. Attaching in a `useLayoutEffect` is too late: layout effects commit
* children-before-parent, so every `autoFocus` descendant called `.focus()` on a node that was
* still disconnected, where `focus()` is a no-op. Every autoFocus in every dialog was dead.
* 2. **`appendChild` MOVES the node, it does not teleport it.** Per the DOM spec an already-parented
* node is removed and re-inserted, and removing a focused element's ancestor resets focus to
* `<body>` — so the focused element is saved and re-focused across the move.
*/
export function useFullscreenPortalHost(): HTMLElement | null {
const fsRoot = useFullscreenElement();
const [host] = useState<HTMLElement | null>(() =>
typeof document === "undefined" ? null : document.createElement("div"),
);
useInsertionEffect(() => {
if (host && !host.parentNode) document.body.appendChild(host);
}, [host]);
useLayoutEffect(() => {
if (!host) return;
// When the fullscreen element is the overlay's OWN content (a <video> inside a dialog, put in
// fullscreen by its key or its own button), it already shows; moving the host into its own
// descendant is a move the DOM refuses (HierarchyRequestError, the whole app crashed).
const parent = fsRoot && !host.contains(fsRoot) ? fsRoot : document.body;
if (host.parentNode === parent) return;
const focused = host.contains(document.activeElement)
? (document.activeElement as HTMLElement)
: null;
parent.appendChild(host);
focused?.focus();
}, [fsRoot, host]);
useEffect(() => () => host?.remove(), [host]);
return host;
}