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.
112 lines
5.4 KiB
TypeScript
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;
|
|
}
|