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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user