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 });