import { cloneElement, isValidElement, useCallback, useContext, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode, type Ref, } from "react"; import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; import { PageToolbarSlot } from "./pageToolbarSlot"; import { PageHeaderSlot } from "./pageHeaderSlot"; import { PageHeadingProvider } from "./pageHeading"; import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot"; import { isBackgroundPoint } from "../../lib/backgroundTarget"; import { pointOnSelection } from "../../lib/textSelection"; // A module renders its per-module fixed band (filter chips / sort / counts) through this. The // content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content // scrolls and is never touched by the scroller's edge-fade mask. It's a portal, so the toolbar's // event handlers and closures still belong to the module's React subtree (setFilters etc. work as // if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and // nothing when there's no shell — so a module works with or without one. export function PageToolbar({ children }: { children: ReactNode }) { const slot = useContext(PageToolbarSlot); return slot ? createPortal(children, slot) : null; } // A page renders its controls in the floating header (its search box, scope toggles, sync chip) // through this, the same way as PageToolbar: a portal, so the controls stay in the page's own React // subtree and reach its state directly (Plex's toggles drive the sub-view that lives in PlexBrowse). // The header itself knows no page. Claiming the header hides the shell's default (PageHeaderDefault). export function PageHeader({ children }: { children: ReactNode }) { const slot = useContext(PageHeaderSlot); const claim = slot?.claim; useLayoutEffect(() => claim?.(), [claim]); return slot?.target ? createPortal(children, slot.target) : null; } // What the header shows on a page that renders no PageHeader of its own (the palette's entry). App // renders it beside the page, inside the page's Suspense, so a page still loading shows no default // that its own controls would replace a moment later. export function PageHeaderDefault({ children }: { children: ReactNode }) { const slot = useContext(PageHeaderSlot); const shown = !!slot?.target && !slot.claimed; const showDefault = slot?.showDefault; useLayoutEffect(() => { showDefault?.(shown); return () => showDefault?.(false); }, [shown, showDefault]); return shown && slot?.target ? createPortal(children, slot.target) : null; } // The page shell: the fixed chrome around a single scrollable content region. It formalizes "what // is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page // (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate // branch that rendered its OWN scroller) collapses to one contract and one scroller. // // Regions, top to bottom: // - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads // its top by that height so nothing hides under it. Omit it (channel page) → no pad // and no fixed top band, so the module's own banner can scroll from the very top. // The header is an empty band: the page fills it through PageHeader. // - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are // the visible identity, and a screen reader still lands on a named page. A view that // shows its own title renders it through `PageHeading`, and this one steps aside. // - `banners` — in-flow, fixed notices (verify/version) above the scroll region. // - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a // module via . Empty ⇒ zero height, so pages without one lose nothing. // - children — the ONE scrollable region. `PageScroller` owns the scroller and its two // invariants (`scrollbar-gutter: stable`, edge fade); see that file. // // The shell renders a fragment so it drops straight into App's `relative` content column — the // floating header keeps resolving its `absolute` position against that same ancestor. export default function PageShell({ title, header, banners, children, scrollKey, maxWidth, }: { title?: string; header?: ReactNode; banners?: ReactNode; children: ReactNode; // Identifies the logical view for the scroll restore/reset policy — see PageScroller. scrollKey?: string; // The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via // --content-mw. Omit for a full-width page (feed). See index.css --content-mw. maxWidth?: string; }) { const [toolbarSlot, setToolbarSlot] = useState(null); // Publish this page's content max-width so the floating header (and toolbar band) can centre on the // same width the content column uses. Global (one page shell renders at a time). Also decide whether // a PINNED filter rail should stay OVERLAY (keep the toolbar's fixed leading gutter, don't push the // centred content) or go in-flow: overlay only when the centred content leaves a wide-enough left // margin for the rail to sit in — else pinning would overlap the content, so it pushes (in-flow). useLayoutEffect(() => { const root = document.documentElement; root.style.setProperty("--content-mw", maxWidth ?? "none"); // Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the // nav rail (~4rem) = ~36rem. Below that, or on a full-width page (no maxWidth), pin in-flow (w-67). let lastPxPerRem = 0; const apply = () => { // Convert the rem maxWidth (and the 36rem slack) against the LIVE root font-size, not a hardcoded // 16: index.css scales the root by --font-scale (default 1.06 ≈ 17px, slider 0.9–1.3), and the page's // own content wrapper is a rem `max-w-*`, so a fixed 16 mis-sizes the threshold at every text size. const pxPerRem = parseFloat(getComputedStyle(root).fontSize) || 16; lastPxPerRem = pxPerRem; const mwPx = maxWidth ? maxWidth.endsWith("rem") ? parseFloat(maxWidth) * pxPerRem : parseFloat(maxWidth) : null; const roomy = mwPx != null && window.innerWidth >= mwPx + 36 * pxPerRem; root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem"); }; apply(); window.addEventListener("resize", apply); // The text-size slider changes --font-scale (hence the root font-size) WITHOUT firing resize, so the // rem→px threshold would go stale. Re-run when the root font actually changes; the pxPerRem guard keeps // our own --rail-pinned-w write (a rem string, no font change) from re-triggering the observer. const fontObserver = new MutationObserver(() => { if ((parseFloat(getComputedStyle(root).fontSize) || 16) !== lastPxPerRem) apply(); }); fontObserver.observe(root, { attributes: true, attributeFilter: ["style"] }); return () => { window.removeEventListener("resize", apply); fontObserver.disconnect(); root.style.removeProperty("--content-mw"); root.style.removeProperty("--rail-pinned-w"); }; }, [maxWidth]); // The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL // height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad // would let the content toolbar slide up under it. Measure the header and write its height // straight onto the content wrapper that consumes `--hdr-h`. // // Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`: // PageShell is reused — never remounted — across feed↔channel navigation (same component position in // App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps // observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with // a 0 box and rewrites --hdr-h to `0+0+4`=4px on the (still-mounted) content div. On Back the pad class // returns with that stale 4px → the content toolbar slides up under the header, stuck until reload. Keying // the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct. const [headerEl, setHeaderEl] = useState(null); const [contentEl, setContentEl] = useState(null); // How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one // that leaves releases only its own claim. const [headerClaims, setHeaderClaims] = useState(0); // Bumped by every claim and every release. The count alone cannot say the pills changed: when one // page's PageHeader replaces another's in one commit (feed → playlists), the release and the claim // net to the same count, and the measurement below would not re-run. const [headerGeneration, setHeaderGeneration] = useState(0); const claimHeader = useCallback(() => { setHeaderClaims((n) => n + 1); setHeaderGeneration((g) => g + 1); return () => { setHeaderClaims((n) => n - 1); setHeaderGeneration((g) => g + 1); }; }, []); // Whether the shell's default is in the header (PageHeaderDefault reports it). const [defaultShown, setDefaultShown] = useState(false); // The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement // saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the // content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs // here, before paint, whenever what fills the header changes (a claim made or released, the default // shown or gone); the observer still covers a change within a page's own pills (Plex's view switch // appearing). useLayoutEffect(() => { if (!headerEl || !contentEl) { // No header (channel page) → drop any stale pad so a leftover value can't linger. contentEl?.style.removeProperty("--hdr-h"); return; } // `+ 4` = a tight gap below the floating header before the content toolbar starts. const apply = () => contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 4}px`); apply(); const ro = new ResizeObserver(apply); ro.observe(headerEl); return () => ro.disconnect(); }, [headerEl, contentEl, headerGeneration, defaultShown]); const headerSlot = { target: headerEl, claimed: headerClaims > 0, claim: claimHeader, showDefault: setDefaultShown, }; // How many views below render their own page `h1` right now (see ./pageHeading). const [ownHeadings, setOwnHeadings] = useState(0); const registerOwnHeading = useCallback(() => { setOwnHeadings((n) => n + 1); return () => setOwnHeadings((n) => n - 1); }, []); // The page's background menu (./pageMenuSlot): the latest registered opener answers. const pageMenus = useRef([]); const registerPageMenu = useCallback((opener: PageMenuOpener) => { pageMenus.current = [...pageMenus.current, opener]; return () => { pageMenus.current = pageMenus.current.filter((o) => o !== opener); }; }, []); // What pressed last, for an engine whose contextmenu event carries no pointerType. const lastPointer = useRef("mouse"); const onContextMenu = (e: React.MouseEvent) => { const open = pageMenus.current[pageMenus.current.length - 1]; // Shift+right-click is the way to the browser's own menu; a row's or a link's handler has claimed // the event already (they stop it); selected words keep the browser's Copy / Search. if (!open || e.shiftKey || e.defaultPrevented) return; // Desktop only: a touch long-press on the background belongs to scrolling and text selection. const pointer = (e.nativeEvent as Partial).pointerType || lastPointer.current; if (pointer !== "mouse") return; if (pointOnSelection(e.target, e.clientX, e.clientY)) return; if (!isBackgroundPoint(e.target, e.currentTarget)) return; e.preventDefault(); open(e.clientX, e.clientY, e.currentTarget); }; const measuredHeader = isValidElement(header) ? cloneElement(header as ReactElement<{ ref?: Ref }>, { ref: setHeaderEl, }) : header; return ( {measuredHeader}
{ lastPointer.current = e.pointerType; }} onContextMenu={onContextMenu} > {title && ownHeadings === 0 &&

{title}

} {banners} {/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
{children}
); }