feat(popover): player description card on the platform, add-to-playlist works in fullscreen (R8 S4d)

The description hover card loses its two hand-managed timers and its fixed `bottom`
anchor. It now opens above the title through the platform, which means `flip` can rescue
it when there is no room up there either — the old fixed anchor could only grow upward.
Its width moves from a px clamp to a rem one, so it follows the font-scale slider like
the rest of the app.

Add-to-playlist is no longer disabled in the player's true fullscreen: its menu rides
the platform, which portals into the fullscreen element rather than <body>. The download
button stays hidden there on purpose — it opens a Modal, and Modal still portals to
<body>, so its dialog would open invisibly. Lifting that needs the same fullscreen-aware
root inside Modal.

NOT migrated, deliberately: PlexPlayer's gear and tracks panels. They are the one place
where portalling would be a visual regression — the control bar sits inside the player's
`transform: scale(1.25)` windowed subtree, so a panel portaled out of it renders at 80%
of the surrounding UI's size. Positioning would still be correct (floating-ui reads
post-transform viewport rects); the panel's own type and padding would not. Doing this
properly means either portalling into that scaled wrapper or dropping the scale, and
either way it needs a UAT with a playing video that this environment cannot run.

Verified in-browser: the description card opens after its hover-intent delay, anchored
8px above the title and left-aligned to it, with the description loaded.
This commit is contained in:
2026-07-31 00:16:41 +02:00
parent e35c4be561
commit 2727b59fa5
+47 -74
View File
@@ -27,6 +27,7 @@ import {
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
@@ -446,41 +447,21 @@ export default function PlayerModal({
}
};
// Lazy description (fetched only when the title is hovered). The popover is
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
// can't clip it. A small close grace lets the mouse travel title → popover.
// Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
// pair of hand-managed timers.
const [showDesc, setShowDesc] = useState(false);
// `bottom` anchors the popover just above the title so it grows upward (over the
// player) instead of downward off-screen / behind the OS taskbar.
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
null,
);
const titleRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const openTimer = useRef<number | undefined>(undefined);
const openDescNow = () => {
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
const el = titleRef.current;
if (el) {
const r = el.getBoundingClientRect();
const width = Math.min(560, window.innerWidth - 32);
const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
}
setShowDesc(true);
};
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
// quick pass over it doesn't flash the popover.
const scheduleOpenDesc = () => {
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
openTimer.current = window.setTimeout(openDescNow, 400);
};
const scheduleCloseDesc = () => {
window.clearTimeout(openTimer.current);
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
};
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
mode: "hover",
placement: "top-start",
offset: 8,
hoverDelay: { open: 400, close: 150 },
});
const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
@@ -573,8 +554,6 @@ export default function PlayerModal({
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
window.clearTimeout(volTimerRef.current);
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
// way to leave) must not drop the level the user just set.
@@ -960,12 +939,18 @@ export default function PlayerModal({
<span className="h-4 w-px bg-white/20" />
</>
)}
{!navigated && !isFullscreen && (
{/* Works in fullscreen now: its menu rides the popover platform, which portals into
the fullscreen element instead of <body> (a body-portaled node is painted
underneath the element holding the lock, i.e. invisible). */}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{/* Still hidden in fullscreen: this one opens a Modal, not a popover, and Modal
still portals to <body> — so the dialog would open invisibly. Lifting that needs
the same fullscreen-aware root in Modal. */}
{!navigated && !isFullscreen && (
<DownloadButton
videoId={active.id}
@@ -1128,10 +1113,9 @@ export default function PlayerModal({
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={titleRef}
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
@@ -1146,43 +1130,32 @@ export default function PlayerModal({
{t("player.back")}
</button>
)}
{showDesc &&
descRect &&
createPortal(
<PopoverSurface
popover={descPop}
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
onMouseLeave={scheduleCloseDesc}
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body,
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</PopoverSurface>
<button
data-testid="player-close"
onClick={onClose}