diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index df90ea3c..60488c36 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { emit, listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { useTranslation } from 'react-i18next'; @@ -114,7 +115,9 @@ export default function MiniPlayer() { const [volumeOpen, setVolumeOpen] = useState(false); const ticker = useRef(null); const queueScrollRef = useRef(null); - const volumeWrapRef = useRef(null); + const volumeBtnRef = useRef(null); + const volumePopRef = useRef(null); + const [volumePopStyle, setVolumePopStyle] = useState({}); const hiddenRef = useRef(false); const isHidden = useWindowVisibility(); useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]); @@ -264,13 +267,58 @@ export default function MiniPlayer() { handleVolumeChange(volume === 0 ? 1 : 0); }; - // Close the volume popover on outside click / Escape. + // Position the portaled volume popover relative to its trigger button. + // Auto-flip above when there is not enough room below (mini window is short). + const updateVolumePopStyle = () => { + if (!volumeBtnRef.current) return; + const rect = volumeBtnRef.current.getBoundingClientRect(); + const MARGIN = 6; + const POP_W = 40; + const POP_H = 150; + const spaceBelow = window.innerHeight - rect.bottom - MARGIN; + const spaceAbove = rect.top - MARGIN; + const useAbove = spaceBelow < POP_H && spaceAbove > spaceBelow; + const left = Math.min( + Math.max(rect.left + rect.width / 2 - POP_W / 2, 6), + window.innerWidth - POP_W - 6, + ); + setVolumePopStyle({ + position: 'fixed', + left, + width: POP_W, + ...(useAbove + ? { bottom: window.innerHeight - rect.top + MARGIN } + : { top: rect.bottom + MARGIN }), + zIndex: 99998, + }); + }; + + useLayoutEffect(() => { + if (!volumeOpen) return; + updateVolumePopStyle(); + }, [volumeOpen]); + + useEffect(() => { + if (!volumeOpen) return; + const onReposition = () => updateVolumePopStyle(); + window.addEventListener('resize', onReposition); + window.addEventListener('scroll', onReposition, true); + return () => { + window.removeEventListener('resize', onReposition); + window.removeEventListener('scroll', onReposition, true); + }; + }, [volumeOpen]); + + // Close the volume popover on outside click / Escape. The popover is now + // portaled, so check both the trigger button and the popover ref. useEffect(() => { if (!volumeOpen) return; const onDown = (e: MouseEvent) => { - if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) { - setVolumeOpen(false); - } + const target = e.target as Node; + if ( + !volumeBtnRef.current?.contains(target) && + !volumePopRef.current?.contains(target) + ) setVolumeOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setVolumeOpen(false); @@ -453,8 +501,9 @@ export default function MiniPlayer() {
-
+
- {volumeOpen && ( -
+ {volumeOpen && createPortal( +
{Math.round(volume * 100)}%
-
+
, + document.body, )}
diff --git a/src/styles/components.css b/src/styles/components.css index fbf7b3b3..b9f43273 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -10137,11 +10137,11 @@ html.no-compositing .fs-seekbar-played { } .mini-player__volume-popover { - position: absolute; - top: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - z-index: 50; + /* Positioned via inline `style` (createPortal'd to document.body, so + `position: fixed` + computed left/top from the trigger button's rect). + Falls back gracefully if inline style is missing. */ + position: fixed; + z-index: 99998; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 8px;