From 5c87a941701a2e5602c3c2b547f3f287073a2d48 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:02:02 +0200 Subject: [PATCH] fix(mini-player): portal volume popover so it cannot get clipped (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.mini-player` has `overflow: hidden`, and the absolute-positioned volume popover (`top: calc(100% + 6px)`, ~150 px tall) was being clipped when the queue was closed and the mini window was at its short height — visually the slider rendered outside the visible area, even though it stayed fully usable. Opening the queue grew the window enough to bring the popover back inside the clip rect. Render the popover via `createPortal` to `document.body` and position it with `position: fixed` based on the trigger button's `getBoundingClientRect`, mirroring the YearFilterButton pattern. Auto-flips above when there is not enough room below (short mini windows). Repositions on window resize / scroll. Outside-click handler now checks both the trigger button and the popover ref since they no longer share a DOM ancestor. --- src/components/MiniPlayer.tsx | 75 ++++++++++++++++++++++++++++++----- src/styles/components.css | 10 ++--- 2 files changed, 70 insertions(+), 15 deletions(-) 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;