From b61c168430d904f5065a91d82584a801b09f028c Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:44:28 +0300 Subject: [PATCH] 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. --- src-tauri/src/lib.rs | 8 +- src/App.tsx | 102 ++++++++++++------- src/components/MiniPlayer.tsx | 73 ++++++-------- src/components/OverlayScrollArea.tsx | 141 +++++++++++++++++++++++++++ src/components/QueuePanel.tsx | 23 +++-- src/components/Sidebar.tsx | 20 ++++ src/constants/appScroll.ts | 2 + src/pages/Genres.tsx | 19 ++-- src/styles/components.css | 36 +------ src/styles/layout.css | 138 +++++++++++++++++++++++++- src/styles/theme.css | 7 ++ src/utils/overlayScrollbarMetrics.ts | 33 +++++++ src/utils/overlayScrollbarThumb.ts | 66 +++++++++++++ 13 files changed, 538 insertions(+), 130 deletions(-) create mode 100644 src/components/OverlayScrollArea.tsx create mode 100644 src/constants/appScroll.ts create mode 100644 src/utils/overlayScrollbarMetrics.ts create mode 100644 src/utils/overlayScrollbarThumb.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index edf2f620..6ee999fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,8 +132,12 @@ fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle #[cfg(target_os = "linux")] { use tauri::Manager; - if let Some(win) = app_handle.get_webview_window("main") { - linux_webkit_apply_smooth_scrolling(&win, enabled)?; + // Each WebviewWindow has its own WebKitGTK Settings — main-only left the + // mini player on the default (inertial) wheel until the user toggled again. + for label in ["main", "mini"] { + if let Some(win) = app_handle.get_webview_window(label) { + linux_webkit_apply_smooth_scrolling(&win, enabled)?; + } } } #[cfg(not(target_os = "linux"))] diff --git a/src/App.tsx b/src/App.tsx index 4c19dd64..e53b163c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,6 +52,8 @@ import SongInfoModal from './components/SongInfoModal'; import DownloadFolderModal from './components/DownloadFolderModal'; import { DragDropProvider } from './contexts/DragDropContext'; import TooltipPortal from './components/TooltipPortal'; +import OverlayScrollArea from './components/OverlayScrollArea'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll'; import ConnectionIndicator from './components/ConnectionIndicator'; import LastfmIndicator from './components/LastfmIndicator'; import OfflineBanner from './components/OfflineBanner'; @@ -98,6 +100,28 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return <>{children}; } +/** + * Avoid grabbing the queue resizer when aiming at the main overlay scrollbar. + * Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that). + * Only the main-route thumb counts (not queue/mini thumbs, which share the same class). + */ +function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, queueWidth: number): boolean { + const vp = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null; + const mainRight = vp ? vp.getBoundingClientRect().right : window.innerWidth - queueWidth; + if (clientX <= mainRight) return true; + + const thumbs = document.querySelectorAll('.app-shell-route-scroll .overlay-scroll__thumb'); + const xSlop = 22; + const vPad = 40; + for (let i = 0; i < thumbs.length; i++) { + const r = thumbs[i].getBoundingClientRect(); + if (r.height < 4 || r.width < 1) continue; + if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue; + if (clientX >= r.left - 6 && clientX <= r.right + xSlop) return true; + } + return false; +} + function AppShell() { const { t } = useTranslation(); const isMobile = useIsMobile(); @@ -209,9 +233,9 @@ function AppShell() { }; }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); - // Reset scroll position on route change + // Reset scroll position on route change (main viewport is overlay scroll) useEffect(() => { - document.querySelector('.content-body')?.scrollTo({ top: 0 }); + document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); }, [location.pathname]); // Auto-navigate to offline library when no connection but cached content exists @@ -404,38 +428,46 @@ function AppShell() { {connStatus === 'disconnected' && ( )} -
- - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - +
+ + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
@@ -444,6 +476,8 @@ function AppShell() { className="resizer resizer-queue" onMouseDown={(e) => { e.preventDefault(); + if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) return; + if (shouldSuppressQueueResizerMouseDown(e.clientX, e.clientY, queueWidth)) return; setIsDraggingQueue(true); }} style={{ display: isQueueVisible ? 'block' : 'none' }} diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 2d470d3a..c867c537 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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(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('.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() { {queueOpen && ( -
{ if (!isReorderDrag || !queueScrollRef.current) return; const items = queueScrollRef.current.querySelectorAll('[data-mq-idx]'); @@ -591,7 +587,6 @@ export default function MiniPlayer() { setDropTarget(null); }} > -
{state.queue.length === 0 ? (
{t('miniPlayer.emptyQueue')}
) : ( @@ -651,17 +646,7 @@ export default function MiniPlayer() { ); }) )} -
- {scrollMeta.visible && ( -
- )} -
+ )}
diff --git a/src/components/OverlayScrollArea.tsx b/src/components/OverlayScrollArea.tsx new file mode 100644 index 00000000..c91b395a --- /dev/null +++ b/src/components/OverlayScrollArea.tsx @@ -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; + /** 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; + /** 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; + /** Optional id on the viewport (e.g. main app scroll for route pages). */ + viewportId?: string; +}; + +const RAIL_INSET_CLASS: Record = { + none: 'overlay-scroll--rail-inset-none', + mini: 'overlay-scroll--rail-inset-mini', + panel: 'overlay-scroll--rail-inset-panel', +}; + +function assignRef(ref: React.Ref | 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(null); + const viewportRef = useRef(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('.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('.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 ( +
+
+ {children} +
+ {meta.visible && ( +
+
bindOverlayScrollbarThumbDrag(ev, viewportRef.current)} + /> +
+ )} +
+ ); +} diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index d4eb70cf..d54d10f2 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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(null); const queueListRef = useRef(null); + const asideRef = useRef(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() {
{currentTrack && queue.length > 0 &&
{t('queue.nextTracks')}
} - -
+ + {queue.length === 0 ? (
{t('queue.emptyQueue')} @@ -696,7 +707,7 @@ export default function QueuePanel() { ); }) )} -
+
) : activeTab === 'lyrics' ? ( ) : ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6a11286f..dd4a9a14 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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({ ); diff --git a/src/constants/appScroll.ts b/src/constants/appScroll.ts new file mode 100644 index 00000000..88bf0b5c --- /dev/null +++ b/src/constants/appScroll.ts @@ -0,0 +1,2 @@ +/** Main scroll element wrapping `` in App (overlay scrollbar). */ +export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport'; diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index bb058229..66b44e72 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -7,7 +7,7 @@ import { Tags, type LucideIcon, } from 'lucide-react'; import { getGenres, SubsonicGenre } from '../api/subsonic'; -import { useAuthStore } from '../store/authStore'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; function getGenreIcon(name: string): LucideIcon { const n = name.toLowerCase(); @@ -59,7 +59,6 @@ export default function Genres() { const saved = sessionStorage.getItem(VISIBLE_KEY); return saved ? Math.max(PAGE_SIZE, parseInt(saved, 10)) : PAGE_SIZE; }); - const containerRef = useRef(null); const observerTarget = useRef(null); useEffect(() => { @@ -83,9 +82,13 @@ export default function Genres() { // paint of the page. useEffect(() => { if (!hasMore) return; + const root = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); }, - { rootMargin: '1500px' }, + { + root: root instanceof HTMLElement ? root : null, + rootMargin: '1500px', + }, ); if (observerTarget.current) observer.observe(observerTarget.current); return () => observer.disconnect(); @@ -100,20 +103,22 @@ export default function Genres() { sessionStorage.removeItem(SCROLL_KEY); sessionStorage.removeItem(VISIBLE_KEY); requestAnimationFrame(() => { - if (containerRef.current) containerRef.current.scrollTop = pos; + const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + if (el) el.scrollTop = pos; }); }, [loading, genres.length]); const handleGenreClick = (genreValue: string) => { - if (containerRef.current) { - sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop)); + const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + if (el) { + sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop)); sessionStorage.setItem(VISIBLE_KEY, String(visibleCount)); } navigate(`/genres/${encodeURIComponent(genreValue)}`); }; return ( -
+

{t('genres.title')}

{!loading && genres.length > 0 && ( diff --git a/src/styles/components.css b/src/styles/components.css index 24a8c07d..2aef9a5c 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -9418,47 +9418,19 @@ html.no-compositing .fs-seekbar-played { } -.mini-queue-wrap { +.mini-queue-wrap.overlay-scroll { flex: 1 1 auto; - position: relative; background: var(--bg-card); border-radius: 8px; - overflow: hidden; - min-height: 0; margin-top: 10px; } -.mini-queue { - height: 100%; - overflow-y: auto; - overscroll-behavior: contain; +.mini-queue.overlay-scroll__viewport { padding: 4px; display: flex; flex-direction: column; gap: 1px; box-sizing: border-box; - /* Hide native scrollbar — we render an overlay thumb beside it. */ - scrollbar-width: none; -} -.mini-queue::-webkit-scrollbar { - display: none; -} - -.mini-queue__thumb { - position: absolute; - top: 4px; - right: 3px; - width: 4px; - border-radius: 2px; - background: var(--text-muted); - opacity: 0; - transition: opacity 0.18s ease; - pointer-events: none; - will-change: transform; -} -.mini-queue-wrap:hover .mini-queue__thumb, -.mini-queue.is-scrolling ~ .mini-queue__thumb { - opacity: 0.55; } .mini-queue__empty { @@ -9490,10 +9462,6 @@ html.no-compositing .fs-seekbar-played { background: var(--bg-hover); } -.mini-queue-wrap--drop-active .mini-queue { - scroll-behavior: auto; -} - .mini-queue__item--current { background: var(--accent-dim); color: var(--accent); diff --git a/src/styles/layout.css b/src/styles/layout.css index fb7da77d..ad2b637d 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -197,6 +197,13 @@ .resizer-queue { right: calc(var(--queue-width) - 3px); } +/* While dragging an overlay scrollbar thumb, hide queue resizer so the + pointer does not pick up col-resize / queue resize (thumb sits under the + resizer hit box near the main/queue edge). */ +body.is-overlay-scrollbar-thumb-drag .resizer { + pointer-events: none !important; +} + /* In native fullscreen the resizer sits flush against the screen edge and would interfere with OS edge-swipe gestures — hide it entirely. */ .app-shell[data-fullscreen] .resizer { display: none; } @@ -242,11 +249,22 @@ .sidebar-nav { flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-nav-scroll.overlay-scroll { + flex: 1; + min-height: 0; +} + +.sidebar-nav-scroll .overlay-scroll__viewport.sidebar-nav-viewport { padding: var(--space-1) var(--space-3) var(--space-4); display: flex; flex-direction: column; gap: var(--space-1); - overflow-y: auto; } /* Library scope: Navidrome-style dropdown trigger + fixed panel (portal) */ @@ -1086,6 +1104,28 @@ overflow: visible; } +/* Main route stack: scroll + overlay thumb live in the inner viewport, not the + outer .content-body, so sticky headers still anchor to the scrolling element. */ +.content-body.app-shell-route-host { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 0; + position: relative; + contain: none; +} + +.app-shell-route-scroll.overlay-scroll { + flex: 1; + min-height: 0; +} + +.app-shell-route-scroll .app-shell-route-scroll__viewport { + contain: paint; +} + /* Sticky page header: keeps page title + filter/search bar visible while the body scrolls. Negative horizontal margins (+ matching padding) make the background flush with the scroll container edges so the sticky block looks @@ -1907,9 +1947,101 @@ html[data-platform="windows"] .player-bar.floating { flex-shrink: 0; } -.queue-list { - flex: 1; +/* ─── OverlayScrollArea (shared: queue, mini, sidebar, menus) ─────────────── */ +.overlay-scroll { + --overlay-rail-top: 0px; + --overlay-rail-bottom: 0px; + position: relative; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; +} + +.overlay-scroll--rail-inset-none { + --overlay-rail-top: 0px; + --overlay-rail-bottom: 0px; +} + +.overlay-scroll--rail-inset-mini { + --overlay-rail-top: 4px; + --overlay-rail-bottom: 4px; +} + +.overlay-scroll--rail-inset-panel { + --overlay-rail-top: var(--space-2); + --overlay-rail-bottom: var(--space-2); +} + +.overlay-scroll--viewport-scroll-auto .overlay-scroll__viewport { + scroll-behavior: auto; +} + +.overlay-scroll__viewport { + flex: 1 1 auto; + min-height: 0; overflow-y: auto; + overscroll-behavior: contain; + box-sizing: border-box; + scrollbar-width: none; +} + +.overlay-scroll__viewport::-webkit-scrollbar { + display: none; +} + +.overlay-scroll__rail { + position: absolute; + top: var(--overlay-rail-top); + right: 0; + bottom: var(--overlay-rail-bottom); + width: 14px; + z-index: 1; + pointer-events: auto; +} + +.overlay-scroll__thumb { + position: absolute; + top: 0; + right: 2px; + width: 3px; + border-radius: 2px; + background: var(--text-muted); + opacity: 0; + transition: + opacity 0.18s ease, + width 0.2s ease, + border-radius 0.2s ease; + pointer-events: none; + will-change: transform; + cursor: grab; + touch-action: none; +} + +.overlay-scroll__viewport.is-scrolling ~ .overlay-scroll__rail .overlay-scroll__thumb, +.overlay-scroll:hover .overlay-scroll__rail .overlay-scroll__thumb { + opacity: 0.55; + pointer-events: auto; +} + +.overlay-scroll__rail:hover .overlay-scroll__thumb, +.overlay-scroll__rail:has(.overlay-scroll__thumb.is-thumb-dragging) .overlay-scroll__thumb { + width: 9px; + border-radius: 4px; +} + +.overlay-scroll__rail .overlay-scroll__thumb.is-thumb-dragging { + opacity: 0.9; + pointer-events: auto; + cursor: grabbing; + transition: opacity 0.18s ease; +} + +.queue-list-wrap.overlay-scroll { + flex: 1 1 auto; +} + +.queue-list.overlay-scroll__viewport { padding: var(--space-2); } diff --git a/src/styles/theme.css b/src/styles/theme.css index 315953b4..5a6d62da 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3706,6 +3706,13 @@ body.is-dragging * { cursor: col-resize !important; } +/* Overlay scrollbar thumb drag — override queue resizer cursor (z-index). */ +body.is-overlay-scrollbar-thumb-drag, +body.is-overlay-scrollbar-thumb-drag * { + user-select: none !important; + cursor: grabbing !important; +} + /* ─── Mouse-event DnD cursor (psy-dragging) ────────────────────── Applied by DragDropProvider while a mouse-based drag is active. Overrides cursor globally to "grabbing" and prevents text- diff --git a/src/utils/overlayScrollbarMetrics.ts b/src/utils/overlayScrollbarMetrics.ts new file mode 100644 index 00000000..a320bec8 --- /dev/null +++ b/src/utils/overlayScrollbarMetrics.ts @@ -0,0 +1,33 @@ +export type OverlayScrollbarThumbMeta = { + thumbH: number; + thumbT: number; + visible: boolean; +}; + +/** + * @param trackHeight — pixel height of the overlay rail (inset top/bottom). + * When shorter than the viewport, thumb size/position must use this or the + * thumb’s bottom extends past the visible rail at max scroll. + */ +export function computeOverlayScrollbarThumbMeta( + el: HTMLElement | null, + trackHeight?: number, +): OverlayScrollbarThumbMeta { + if (!el) return { thumbH: 0, thumbT: 0, visible: false }; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight <= clientHeight + 1) { + return { thumbH: 0, thumbT: 0, visible: false }; + } + const th = + trackHeight != null && trackHeight > 0 ? Math.min(trackHeight, clientHeight) : clientHeight; + const ratio = clientHeight / scrollHeight; + const rawH = Math.round(ratio * th); + const thumbH = Math.min(th, Math.max(24, rawH)); + const range = Math.max(0, th - thumbH); + const scrollRange = scrollHeight - clientHeight; + const thumbT = + scrollRange > 0 + ? Math.min(range, Math.max(0, Math.round((scrollTop / scrollRange) * range))) + : 0; + return { thumbH, thumbT, visible: true }; +} diff --git a/src/utils/overlayScrollbarThumb.ts b/src/utils/overlayScrollbarThumb.ts new file mode 100644 index 00000000..04bf3b2e --- /dev/null +++ b/src/utils/overlayScrollbarThumb.ts @@ -0,0 +1,66 @@ +import type { PointerEvent as ReactPointerEvent } from 'react'; +import { computeOverlayScrollbarThumbMeta } from './overlayScrollbarMetrics'; + +/** + * Drag the overlay scrollbar thumb (native bar is hidden). Maps pointer delta + * to scrollTop using the same thumb/range geometry as the visual thumb. + */ +export function bindOverlayScrollbarThumbDrag( + e: ReactPointerEvent, + scrollEl: HTMLDivElement | null, +): void { + if (e.button !== 0 || !scrollEl) return; + e.preventDefault(); + e.stopPropagation(); + + const thumb = e.currentTarget; + const rail = thumb.parentElement; + const trackH = + rail instanceof HTMLElement && rail.clientHeight > 0 ? rail.clientHeight : scrollEl.clientHeight; + const meta = computeOverlayScrollbarThumbMeta(scrollEl, trackH); + if (!meta.visible) return; + + const { scrollHeight, clientHeight } = scrollEl; + const scrollRange = scrollHeight - clientHeight; + const range = trackH - meta.thumbH; + if (range <= 1) return; + + const startScroll = scrollEl.scrollTop; + const startY = e.clientY; + const pointerId = e.pointerId; + + thumb.classList.add('is-thumb-dragging'); + document.body.classList.add('is-overlay-scrollbar-thumb-drag'); + try { + thumb.setPointerCapture(pointerId); + } catch { + thumb.classList.remove('is-thumb-dragging'); + document.body.classList.remove('is-overlay-scrollbar-thumb-drag'); + return; + } + + const onMove = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + const dy = ev.clientY - startY; + const next = startScroll + (dy / range) * scrollRange; + scrollEl.scrollTop = Math.max(0, Math.min(scrollRange, next)); + }; + + const onUp = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + thumb.classList.remove('is-thumb-dragging'); + document.body.classList.remove('is-overlay-scrollbar-thumb-drag'); + try { + thumb.releasePointerCapture(pointerId); + } catch { + /* ignore */ + } + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); +}