fix(ui): overlay scrollbars, resizer hit-test, and Linux mini wheel (#255)

Add OverlayScrollArea with shared thumb metrics and drag handling; size the
thumb against the rail track height so panel insets cannot push it past the
visible rail. Route scroll uses a stable viewport id; Genres restores scroll
and infinite-scroll observation against that viewport (merged with upstream
virtualized genres list and lazy routes behind Suspense).

Suppress queue resizer activation when the pointer targets the main-route
overlay scrollbar; disable the resizer while dragging the thumb and use a
grabbing cursor on the body.

Apply WebKitGTK smooth wheel to the mini webview as well as main; the mini
window reapplies the persisted setting after auth store hydration.
This commit is contained in:
cucadmuh
2026-04-21 23:44:28 +03:00
committed by GitHub
parent 2318f9e07a
commit b61c168430
13 changed files with 538 additions and 130 deletions
+29 -44
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
@@ -6,10 +6,12 @@ import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusi
import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import OverlayScrollArea from './OverlayScrollArea';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
const COLLAPSED_SIZE = { w: 340, h: 260 };
@@ -107,7 +109,6 @@ export default function MiniPlayer() {
});
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null);
@@ -137,25 +138,6 @@ export default function MiniPlayer() {
// ── Context menu state ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
// Compute overlay-scrollbar thumb height + offset from the queue's scroll
// metrics. Native scrollbar is hidden via CSS; this thumb floats over the
// items so the queue keeps its full width.
const recomputeScroll = useCallback(() => {
const el = queueScrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollHeight <= clientHeight + 1) {
setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev));
return;
}
const ratio = clientHeight / scrollHeight;
const thumbH = Math.max(24, Math.round(ratio * clientHeight));
const range = clientHeight - thumbH;
const scrollRange = scrollHeight - clientHeight;
const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0;
setScrollMeta({ thumbH, thumbT, visible: true });
}, []);
// Announce to main window that we're mounted; it replies with a snapshot.
// Also re-announce on window focus: on Windows the mini is pre-created at
// app startup so the mount-time emit can race past main's bridge before
@@ -169,6 +151,21 @@ export default function MiniPlayer() {
return () => window.removeEventListener('focus', onFocus);
}, []);
// Mini is a separate WebKitGTK webview: Rust applies smooth-wheel per window.
// Re-send after auth persist hydrates so preloaded/hidden mini matches Settings.
useEffect(() => {
if (!IS_LINUX) return;
const apply = () => {
invoke('set_linux_webkit_smooth_scrolling', {
enabled: useAuthStore.getState().linuxWebkitKineticScroll,
}).catch(() => {});
};
apply();
return useAuthStore.persist.onFinishHydration(() => {
apply();
});
}, []);
// Restore the expanded window size on initial mount when the queue was
// open at the previous app close. Rust always builds the window at the
// collapsed size; without this we'd render queueOpen=true into a 180 px
@@ -360,17 +357,11 @@ export default function MiniPlayer() {
if (!queueOpen) return;
const el = queueScrollRef.current?.querySelector<HTMLElement>('.mini-queue__item--current');
el?.scrollIntoView({ block: 'nearest' });
requestAnimationFrame(() => {
queueScrollRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
});
}, [queueOpen, state.queueIndex]);
// Recompute overlay-thumb on open, queue mutations, and window resize.
useEffect(() => {
if (!queueOpen) return;
recomputeScroll();
const onResize = () => recomputeScroll();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [queueOpen, state.queue.length, recomputeScroll]);
const { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
@@ -571,8 +562,13 @@ export default function MiniPlayer() {
</div>
{queueOpen && (
<div
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
<OverlayScrollArea
viewportRef={queueScrollRef}
className="mini-queue-wrap"
viewportClassName="mini-queue"
measureDeps={[queueOpen, state.queue.length]}
railInset="mini"
viewportScrollBehaviorAuto={isReorderDrag}
onMouseMove={(e) => {
if (!isReorderDrag || !queueScrollRef.current) return;
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[data-mq-idx]');
@@ -591,7 +587,6 @@ export default function MiniPlayer() {
setDropTarget(null);
}}
>
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
{state.queue.length === 0 ? (
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : (
@@ -651,17 +646,7 @@ export default function MiniPlayer() {
);
})
)}
</div>
{scrollMeta.visible && (
<div
className="mini-queue__thumb"
style={{
height: `${scrollMeta.thumbH}px`,
transform: `translateY(${scrollMeta.thumbT}px)`,
}}
/>
)}
</div>
</OverlayScrollArea>
)}
<div className="mini-player__bottom" data-tauri-drag-region="false">
+141
View File
@@ -0,0 +1,141 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
export type OverlayScrollAreaProps = {
children: React.ReactNode;
/** Optional handler on the outer wrapper (e.g. mini queue DnD hit-testing). */
onMouseMove?: React.MouseEventHandler<HTMLDivElement>;
/** Classes on the outer wrapper (e.g. queue-list-wrap, mini-queue-wrap). */
className?: string;
/** Classes on the scrollable viewport (e.g. queue-list, mini-queue). */
viewportClassName?: string;
/** Serialized internally — triggers remeasure + ResizeObserver refresh. */
measureDeps?: ReadonlyArray<unknown>;
/** Vertical inset of the hit rail (align with viewport padding). */
railInset?: OverlayScrollRailInset;
/** e.g. during native DnD — scroll-behavior: auto on the viewport. */
viewportScrollBehaviorAuto?: boolean;
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
viewportRef?: React.Ref<HTMLDivElement>;
/** Optional id on the viewport (e.g. main app scroll for route pages). */
viewportId?: string;
};
const RAIL_INSET_CLASS: Record<OverlayScrollRailInset, string> = {
none: 'overlay-scroll--rail-inset-none',
mini: 'overlay-scroll--rail-inset-mini',
panel: 'overlay-scroll--rail-inset-panel',
};
function assignRef<T>(ref: React.Ref<T> | undefined, value: T) {
if (ref == null) return;
if (typeof ref === 'function') ref(value);
else (ref as { current: T | null }).current = value;
}
export default function OverlayScrollArea({
children,
onMouseMove,
className = '',
viewportClassName = '',
measureDeps = [],
railInset = 'none',
viewportScrollBehaviorAuto = false,
viewportRef: viewportRefProp,
viewportId,
}: OverlayScrollAreaProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
const recompute = useCallback(() => {
const vp = viewportRef.current;
const wrap = wrapRef.current;
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
const trackH =
rail && rail.clientHeight > 0 ? rail.clientHeight : undefined;
setMeta(computeOverlayScrollbarThumbMeta(vp, trackH));
}, []);
const measureKey = JSON.stringify(measureDeps ?? []);
useLayoutEffect(() => {
if (!meta.visible) return;
const vp = viewportRef.current;
const wrap = wrapRef.current;
const rail = wrap?.querySelector<HTMLElement>('.overlay-scroll__rail');
const th = rail?.clientHeight;
if (!vp || !th || th <= 0) return;
setMeta((prev) => {
const next = computeOverlayScrollbarThumbMeta(vp, th);
if (
prev.thumbH === next.thumbH &&
prev.thumbT === next.thumbT &&
prev.visible === next.visible
) {
return prev;
}
return next;
});
}, [meta.visible]);
useEffect(() => {
recompute();
const wrap = wrapRef.current;
const onWinResize = () => recompute();
window.addEventListener('resize', onWinResize);
const ro =
typeof ResizeObserver !== 'undefined' && wrap
? new ResizeObserver(() => recompute())
: null;
if (ro && wrap) ro.observe(wrap);
return () => {
window.removeEventListener('resize', onWinResize);
ro?.disconnect();
};
}, [recompute, measureKey]);
const setViewportNode = (el: HTMLDivElement | null) => {
viewportRef.current = el;
assignRef(viewportRefProp, el);
};
const rootClass = [
'overlay-scroll',
RAIL_INSET_CLASS[railInset],
viewportScrollBehaviorAuto ? 'overlay-scroll--viewport-scroll-auto' : '',
className,
]
.filter(Boolean)
.join(' ');
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
return (
<div ref={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
<div
id={viewportId}
ref={setViewportNode}
className={viewportClass}
onScroll={recompute}
>
{children}
</div>
{meta.visible && (
<div className="overlay-scroll__rail" aria-hidden>
<div
className="overlay-scroll__thumb"
style={{
height: `${meta.thumbH}px`,
transform: `translateY(${meta.thumbT}px)`,
}}
onPointerDown={(ev) => bindOverlayScrollbarThumbDrag(ev, viewportRef.current)}
/>
</div>
)}
</div>
);
}
+17 -6
View File
@@ -1,10 +1,9 @@
import React, { useState, useRef, useMemo } from 'react';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
@@ -14,6 +13,7 @@ import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
import OverlayScrollArea from './OverlayScrollArea';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -291,6 +291,7 @@ export default function QueuePanel() {
const psyDragFromIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
@@ -367,7 +368,10 @@ export default function QueuePanel() {
const nextSong = songs[queueIndex + 1];
if (!nextSong) return;
nextSong.scrollIntoView({ block: "start", behavior: "instant" });
}, [currentTrack, activeTab]);
requestAnimationFrame(() => {
queueListRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
});
}, [currentTrack, activeTab]);
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
@@ -616,8 +620,15 @@ export default function QueuePanel() {
</div>
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<div className="queue-list" ref={queueListRef}>
<OverlayScrollArea
viewportRef={queueListRef}
className="queue-list-wrap"
viewportClassName="queue-list"
measureDeps={[activeTab, queue.length]}
railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag}
>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
@@ -696,7 +707,7 @@ export default function QueuePanel() {
);
})
)}
</div>
</OverlayScrollArea>
</>) : activeTab === 'lyrics' ? (
<LyricsPane currentTrack={currentTrack} />
) : (
+20
View File
@@ -19,6 +19,7 @@ import WhatsNewBanner from './WhatsNewBanner';
import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import OverlayScrollArea from './OverlayScrollArea';
export default function Sidebar({
@@ -155,6 +156,24 @@ export default function Sidebar({
</button>
<nav className="sidebar-nav" aria-label="Main navigation">
<OverlayScrollArea
className="sidebar-nav-scroll"
viewportClassName="sidebar-nav-viewport"
railInset="panel"
measureDeps={[
isCollapsed,
playlistsExpanded,
playlists.length,
isLoggedIn,
randomNavMode,
filterId,
hasOfflineContent,
activeJobs.length,
isSyncing,
syncJobTotal,
sidebarItems.length,
]}
>
{!isCollapsed && (showLibraryPicker ? (
<>
<button
@@ -384,6 +403,7 @@ export default function Sidebar({
)}
</div>
)}
</OverlayScrollArea>
</nav>
</aside>
);