diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 3f112a1..000d8a5 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { @@ -226,8 +227,10 @@ export default function PlexBrowse({ } if (sub.view.kind === "player") { + // The fallback is portaled for the same reason the player itself is: it is a fixed + // full-viewport overlay, and this tree renders inside the page scroller. return ( - }> + , document.body)}> ); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 317c1e0..e7aced3 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -9,6 +9,7 @@ import { type ReactNode, type WheelEvent as ReactWheelEvent, } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Hls from "hls.js"; @@ -944,7 +945,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // While scrubbing the head follows the finger (preview); otherwise it tracks playback. const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct; - return ( + // Portaled to , like every other full-screen overlay here (Modal, PlayerModal, ChatDock, + // BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the + // moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and + // this z-50 is then scoped inside it — the rail, the side panel and the header paint straight + // over the player. That is exactly what the scroller's edge-fade did before this moved out. + return createPortal(
)} - + , + document.body ); } diff --git a/frontend/src/components/ui/DraftSaveBar.tsx b/frontend/src/components/ui/DraftSaveBar.tsx index 5e629af..d2bce62 100644 --- a/frontend/src/components/ui/DraftSaveBar.tsx +++ b/frontend/src/components/ui/DraftSaveBar.tsx @@ -1,3 +1,4 @@ +import { createPortal } from "react-dom"; import { Check, RotateCcw, Save } from "lucide-react"; // The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs, @@ -34,11 +35,15 @@ export function DraftSaveBar({ }) { if (!dirty && state === "idle") return null; const saving = state === "saving"; - const wrap = - variant === "floating" - ? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]" - : "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40"; - return ( + const floating = variant === "floating"; + const wrap = floating + ? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]" + : "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40"; + // The floating variant is fixed to the viewport but renders from a page inside the page + // scroller, so it goes to — otherwise the scroller's stacking context (its edge-fade + // mask) scopes this z-40 and fades the bar's bottom edge. The inline variant is in normal flow + // and stays put. + const bar = (
{state === "saved" ? ( @@ -72,4 +77,5 @@ export function DraftSaveBar({
); + return floating ? createPortal(bar, document.body) : bar; } diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index 749021b..642e787 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -30,18 +30,19 @@ export function useScrollFade(fadePx = 20): { // a thread's messages decrypting, a list being filtered — changes neither the scroller's own // box nor its scrollTop, so without the children nothing would re-evaluate the edges. const ro = new ResizeObserver(update); - const observeAll = () => { - ro.disconnect(); - ro.observe(el); - for (const child of Array.from(el.children)) ro.observe(child); - }; - observeAll(); + ro.observe(el); + for (const child of Array.from(el.children)) ro.observe(child); // Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires // again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the // node observed at mount was the Suspense fallback the real page then replaced. (Same - // lazy-page race that broke BackToTop.) Re-observe whenever the child list changes. - const mo = new MutationObserver(() => { - observeAll(); + // lazy-page race that broke BackToTop.) So follow the child list: the records name exactly + // which nodes arrived and left, which keeps this O(1) per change rather than re-observing + // every child of a long list on each append. + const mo = new MutationObserver((records) => { + for (const rec of records) { + for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node); + for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node); + } update(); }); mo.observe(el, { childList: true });