diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 4aa5bd09..b8c7d1dc 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 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(null); - const closeTimer = useRef(undefined); - const openTimer = useRef(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({ )} - {!navigated && !isFullscreen && ( + {/* Works in fullscreen now: its menu rides the popover platform, which portals into + the fullscreen element instead of (a body-portaled node is painted + underneath the element holding the lock, i.e. invisible). */} + {!navigated && ( )} + {/* Still hidden in fullscreen: this one opens a Modal, not a popover, and Modal + still portals to — so the dialog would open invisibly. Lifting that needs + the same fullscreen-aware root in Modal. */} {!navigated && !isFullscreen && ( {/* Hover target is the text itself (inline), not the whole row. */} {navigated ? (liveData?.title ?? t("player.loading")) : active.title} @@ -1146,43 +1130,32 @@ export default function PlayerModal({ {t("player.back")} )} - {showDesc && - descRect && - createPortal( + +
+ {t("player.description")} +
+ {detail.isLoading ? ( +
{t("player.loading")}
+ ) : detail.data?.description ? (
-
- {t("player.description")} -
- {detail.isLoading ? ( -
{t("player.loading")}
- ) : detail.data?.description ? ( -
- {renderDescription(detail.data.description, { - currentId: currentVideoId, - onSeek: seekTo, - onLoadVideo: loadVideo, - t, - })} -
- ) : ( -
{t("player.noDescription")}
- )} -
, - document.body, + {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + t, + })} + + ) : ( +
{t("player.noDescription")}
)} +