mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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"))]
|
||||
|
||||
+68
-34
@@ -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<HTMLElement>('.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' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<div className="content-body app-shell-route-host">
|
||||
<OverlayScrollArea
|
||||
className="app-shell-route-scroll"
|
||||
viewportClassName="app-shell-route-scroll__viewport"
|
||||
viewportId={APP_MAIN_SCROLL_VIEWPORT_ID}
|
||||
measureDeps={[location.pathname, isQueueVisible, queueWidth, floatingPlayerBar]}
|
||||
railInset="panel"
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Main scroll element wrapping `<Routes />` in App (overlay scrollbar). */
|
||||
export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport';
|
||||
+12
-7
@@ -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<HTMLDivElement>(null);
|
||||
const observerTarget = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="content-body animate-fade-in">
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
|
||||
{!loading && genres.length > 0 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
+135
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<HTMLElement>,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user