fix(plex): portal the full-screen player out of the page scroller

The player is `fixed left-0 top-0 z-50` but rendered from inside the page
scroller, so it only ever covered the viewport because that scroller happened
not to be a stacking context. Giving it the edge-fade made it one, and the mask
scoped the player's z-50 inside it: the rail, the side panel and the header
painted straight over the player, and its edges faded. Measured both ways, then
fixed where every other full-screen overlay here already sits — <body>.

Same for the Suspense fallback that stands in while the player chunk loads, and
for the floating draft save bar on the Configuration page. Swept the rest: every
other `fixed` element is either App-level or already portaled.
This commit is contained in:
2026-07-17 03:30:55 +02:00
parent 0ed58e3cb1
commit 9b4ee83ec8
4 changed files with 34 additions and 17 deletions
+4 -1
View File
@@ -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 (
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
<Suspense fallback={createPortal(<div className="fixed inset-0 z-50 bg-black" />, document.body)}>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense>
);
+9 -2
View File
@@ -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 <body>, 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(
<div
ref={wrapRef}
className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none"
@@ -1619,7 +1625,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
)}
</div>
</div>,
document.body
);
}
+11 -5
View File
@@ -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 <body> — 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 = (
<div className={wrap}>
<span className="text-sm text-muted">
{state === "saved" ? (
@@ -72,4 +77,5 @@ export function DraftSaveBar({
</div>
</div>
);
return floating ? createPortal(bar, document.body) : bar;
}
+10 -9
View File
@@ -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 });