Files
siftlode/frontend/src/lib/fullscreen.ts
T
peter 2756c21dbd feat(popover): fullscreen-aware portal + migrate the hover surfaces (R8 S4a)
The platform's FloatingPortal targeted <body>, which is painted UNDERNEATH whatever
holds the fullscreen lock. That is why the player's in-bar menus were either
non-portaled (and clipped) or, for add-to-playlist / download, disabled outright in
fullscreen. PopoverSurface now portals into `document.fullscreenElement` while one
exists, via a new reactive `useFullscreenElement()` on the existing WebKit-aware
fullscreen lib.

Two floating-ui details this had to work around: a null `root` means "the container
does not exist yet" (no portal node is created at all), so the no-fullscreen case must
pass undefined; and the portal node is built once and ignores a later root change, so
the portal is keyed on the fullscreen state to remount on a transition.

Migrated the three hand-rolled hover engines onto the platform:
- Tooltip: kept as a passive caption via a new `passthrough` prop on PopoverSurface,
  so it cannot swallow a click aimed at a control it overlaps during the close delay.
  Gains aria-describedby wiring it never had.
- AboutCell: the 160 ms hide-timer hover bridge is now safePolygon; the close-on-page-
  scroll is now hide-and-follow, so the card reappears when the row scrolls back.
- TagManager: same bridge removal; the scroll-fade moves to an inner scroller so the
  mask rides the scrolling box rather than the glass panel.

Verified in-browser: tooltip flip + centering + pointer-transparency, About card flip-
above with the measured height, tag popover right-start anchoring, and the portal
round-trip body -> fullscreen element -> body on a live fullscreenchange.
2026-07-30 23:49:29 +02:00

65 lines
3.1 KiB
TypeScript

import { 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 — chiefly the popover platform, 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. */
export function useFullscreenElement(): HTMLElement | null {
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
}