mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
Perf/UI cover cache mainstage (#468)
* Enhance CachedImage and ArtistDetail components with improved image caching and priority handling - Refactor CachedImage to utilize a priority system for image loading based on viewport visibility, improving performance during scrolling. - Update useCachedUrl to accept an optional getPriority function for better cache management. - Optimize ArtistDetail and Artists components by using useMemo for cover art URLs, reducing redundant calculations and improving rendering efficiency. - Adjust image loading logic in CachedImage to ensure smoother transitions and avoid unnecessary fetch requests. * perf(ui): unblock IDB cover art, stabilize mainstage rails and virtual lists Let IndexedDB reads bypass the network concurrency slot so cached thumbnails paint without queueing behind remote fetches; debounce disk eviction during heavy scrolling. Fix mainstage horizontal rails: dedupe album/song ids for React keys, widen artwork budget overscan, avoid resetting the budget on list append, and raise Home initial artwork budgets. CachedImage treats already-decoded images as loaded; rail cards load cover images eagerly. Refresh dynamic color extraction and extend virtual scrolling / scroll roots on Albums, Artists, Playlists, and related surfaces. Remove local agent-only commit instructions from the repository tree. * perf(virtual): viewport-based overscan for main scroll lists Drive TanStack Virtual overscan from measured scroll height so each list renders about one screen of extra rows above and below the viewport for snappier scrolling on Albums, Artists (list mode), and Tracks virtual song list. Introduce useResizeClientHeight helpers (ID + ref) for ResizeObserver-based clientHeight tracking. * docs(changelog): note PR #468 UI cover cache, rails, and virtual lists Add a coarse summary under 1.46.0 Changed for cover-art pipeline, mainstage rails, viewport-based overscan, and library/chrome polish.
This commit is contained in:
@@ -97,7 +97,7 @@ function AlbumCard({
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${album.name} Cover`}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
@@ -135,6 +135,11 @@ export default function AlbumHeader({
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||
|
||||
const lightboxCoverSrc = useMemo(
|
||||
() => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''),
|
||||
[info.coverArt],
|
||||
);
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
const ok = await copyEntityShareLink('album', info.id);
|
||||
@@ -150,7 +155,7 @@ export default function AlbumHeader({
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightboxOpen && info.coverArt && (
|
||||
<CoverLightbox
|
||||
src={buildCoverArtUrl(info.coverArt, 2000)}
|
||||
src={lightboxCoverSrc}
|
||||
alt={`${info.name} Cover`}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
|
||||
+23
-16
@@ -1,10 +1,11 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from './AlbumCard';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -50,6 +51,7 @@ export default function AlbumRow({
|
||||
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]);
|
||||
|
||||
const recomputeArtworkBudget = () => {
|
||||
if (!windowArtworkByViewport) return;
|
||||
@@ -62,19 +64,23 @@ export default function AlbumRow({
|
||||
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
|
||||
const step = Math.max(1, cardW + gap);
|
||||
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
||||
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
|
||||
// Extra slack so fast horizontal scroll doesn’t hit the idx≥budget cliff between frames.
|
||||
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
|
||||
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (interactivityDisabled) return;
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
recomputeArtworkBudget();
|
||||
|
||||
// Auto-load trigger
|
||||
if (!interactivityDisabled) {
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
}
|
||||
|
||||
// Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled)
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
triggerLoadMore();
|
||||
}
|
||||
@@ -90,15 +96,13 @@ export default function AlbumRow({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (interactivityDisabled) return;
|
||||
handleScroll();
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
// One post-layout pass ensures we account for final grid/card geometry.
|
||||
recomputeArtworkBudget();
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
});
|
||||
window.addEventListener('resize', handleScroll);
|
||||
const ro = new ResizeObserver(() => {
|
||||
recomputeArtworkBudget();
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
});
|
||||
if (scrollRef.current) ro.observe(scrollRef.current);
|
||||
return () => {
|
||||
@@ -106,11 +110,14 @@ export default function AlbumRow({
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [albums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||
}, [uniqueAlbums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||
|
||||
// Reset when the row’s identity changes (new data / server), not when the list grows via
|
||||
// “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders.
|
||||
const rowArtworkResetKey = uniqueAlbums[0]?.id ?? '';
|
||||
useEffect(() => {
|
||||
setArtworkBudget(initialArtworkBudget);
|
||||
}, [initialArtworkBudget, albums.length]);
|
||||
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -118,7 +125,7 @@ export default function AlbumRow({
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (albums.length === 0) return null;
|
||||
if (uniqueAlbums.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
@@ -154,8 +161,8 @@ export default function AlbumRow({
|
||||
</div>
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
|
||||
{albums.map((a, idx) => (
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{uniqueAlbums.map((a, idx) => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -17,7 +18,19 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
* loading immediately. Pass false for CSS background-image consumers that
|
||||
* should only see a stable blob URL (prevents a double crossfade).
|
||||
*/
|
||||
export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string {
|
||||
export function useCachedUrl(
|
||||
fetchUrl: string,
|
||||
cacheKey: string,
|
||||
fallbackToFetch = true,
|
||||
getPriority?: () => number,
|
||||
): string {
|
||||
// `buildCoverArtUrl` rotates salt/token on every call — `fetchUrl` is a new
|
||||
// string each render though the logical image is unchanged (`cacheKey`). If
|
||||
// `fetchUrl` were an effect dependency, cleanup would run every frame, call
|
||||
// `releaseUrl`, revoke the blob, and break <img> until onError hides it.
|
||||
const fetchUrlRef = useRef(fetchUrl);
|
||||
fetchUrlRef.current = fetchUrl;
|
||||
|
||||
// Synchronously acquire on first render when the blob is already hot. This
|
||||
// makes the very first <img src> a blob URL, avoiding a fetchUrl→blobUrl
|
||||
// swap that would trigger a redundant network request and decode pass.
|
||||
@@ -26,6 +39,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
// exactly what to release on cleanup or when keys change.
|
||||
const ownedKeyRef = useRef<string | null>(resolved ? cacheKey : null);
|
||||
|
||||
const getPriorityRef = useRef(getPriority);
|
||||
getPriorityRef.current = getPriority;
|
||||
|
||||
useEffect(() => {
|
||||
const release = () => {
|
||||
if (ownedKeyRef.current) {
|
||||
@@ -34,14 +50,17 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
}
|
||||
};
|
||||
|
||||
if (!fetchUrl) {
|
||||
const currentUrl = fetchUrlRef.current;
|
||||
if (!currentUrl) {
|
||||
release();
|
||||
setResolved('');
|
||||
return;
|
||||
return release;
|
||||
}
|
||||
|
||||
// Lazy initializer (or a previous run) already acquired the right key.
|
||||
if (ownedKeyRef.current === cacheKey) return release;
|
||||
// Same logical image as last run — only `cacheKey` drives this effect.
|
||||
if (ownedKeyRef.current === cacheKey) {
|
||||
return release;
|
||||
}
|
||||
|
||||
// Different key than we're currently holding: drop the old one.
|
||||
release();
|
||||
@@ -57,7 +76,7 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
// Slow path: fetch (or read from IDB), then acquire.
|
||||
setResolved('');
|
||||
const controller = new AbortController();
|
||||
getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => {
|
||||
getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => {
|
||||
if (controller.signal.aborted || !blob) return;
|
||||
const url = acquireUrl(cacheKey);
|
||||
if (!url) return;
|
||||
@@ -68,32 +87,59 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
controller.abort();
|
||||
release();
|
||||
};
|
||||
}, [fetchUrl, cacheKey]);
|
||||
}, [cacheKey]);
|
||||
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
||||
const [inView, setInView] = useState(false);
|
||||
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
/**
|
||||
* Drives disk/network waiter ordering only. We intentionally do **not** gate
|
||||
* `useCachedUrl` on intersection — relying on IO to “arm” loading proved brittle
|
||||
* (custom scroll roots, content-visibility, horizontal rails) and led to blank covers.
|
||||
*/
|
||||
const priorityRef = useRef(0);
|
||||
const getViewportImagePriority = useCallback(() => priorityRef.current, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
const root =
|
||||
typeof document !== 'undefined'
|
||||
? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as Element | null)
|
||||
: null;
|
||||
const updateFromEntry = (entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const r = entry.boundingClientRect;
|
||||
const rootEl = entry.rootBounds;
|
||||
const vh = (rootEl?.height ?? window.innerHeight) || 1;
|
||||
const originTop = rootEl?.top ?? 0;
|
||||
const vc = originTop + vh * 0.5;
|
||||
const cy = r.top + r.height * 0.5;
|
||||
const dist = Math.abs(cy - vc);
|
||||
priorityRef.current = entry.intersectionRatio * 1e7 - dist * 1e3;
|
||||
} else {
|
||||
priorityRef.current = -1e12;
|
||||
}
|
||||
};
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
||||
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
||||
entries => { for (const e of entries) updateFromEntry(e); },
|
||||
{
|
||||
root: root ?? undefined,
|
||||
rootMargin: '300px',
|
||||
threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1],
|
||||
},
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
||||
// fallbackToFetch=false: avoid the fetchUrl→blobUrl src swap, which causes the browser
|
||||
// to start a server fetch, then abort it when we replace src with the blob URL —
|
||||
// visible in DevTools as a flood of "Pending / 0 B" requests on Chromium/WebView2.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey, false);
|
||||
// Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves,
|
||||
// then swap to the shared blob URL — avoids an <img> with no src and opacity stuck at 0.
|
||||
// Priority still applies to the slow path inside getCachedBlob.
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey, true, getViewportImagePriority);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
@@ -103,6 +149,26 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
setFallbackSrc(undefined);
|
||||
}, [cacheKey]);
|
||||
|
||||
const isFallback = fallbackSrc !== undefined;
|
||||
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
|
||||
|
||||
// Browsers sometimes skip `load` for cache hits / lazy + horizontal scroll — unstick opacity.
|
||||
useEffect(() => {
|
||||
if (!finalSrc) return;
|
||||
let alive = true;
|
||||
const id = requestAnimationFrame(() => {
|
||||
if (!alive) return;
|
||||
const img = imgRef.current;
|
||||
if (img?.complete && img.naturalWidth > 0) {
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
cancelAnimationFrame(id);
|
||||
};
|
||||
}, [finalSrc]);
|
||||
|
||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (onError) {
|
||||
// Caller wants custom error handling (e.g. hide the element)
|
||||
@@ -114,9 +180,6 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
}
|
||||
};
|
||||
|
||||
const isFallback = fallbackSrc !== undefined;
|
||||
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
@@ -16,6 +16,12 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
};
|
||||
}
|
||||
|
||||
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
|
||||
}
|
||||
|
||||
export default function LiveSearch() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -323,12 +329,7 @@ export default function LiveSearch() {
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
{a.coverArt ? (
|
||||
<CachedImage
|
||||
className="search-result-thumb"
|
||||
src={buildCoverArtUrl(a.coverArt, 40)}
|
||||
cacheKey={coverArtCacheKey(a.coverArt, 40)}
|
||||
alt=""
|
||||
/>
|
||||
<LiveSearchAlbumThumb coverArt={a.coverArt} />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@@ -481,6 +481,14 @@ export default function MiniPlayer() {
|
||||
}, [queueOpen, state.queueIndex]);
|
||||
|
||||
const { track, isPlaying } = state;
|
||||
const miniCoverSrc = useMemo(
|
||||
() => (track?.coverArt ? buildCoverArtUrl(track.coverArt, 300) : ''),
|
||||
[track?.coverArt],
|
||||
);
|
||||
const miniCoverKey = useMemo(
|
||||
() => (track?.coverArt ? coverArtCacheKey(track.coverArt, 300) : ''),
|
||||
[track?.coverArt],
|
||||
);
|
||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
|
||||
return (
|
||||
@@ -540,8 +548,8 @@ export default function MiniPlayer() {
|
||||
<div className="mini-player__art">
|
||||
{track?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(track.coverArt, 300)}
|
||||
cacheKey={coverArtCacheKey(track.coverArt, 300)}
|
||||
src={miniCoverSrc}
|
||||
cacheKey={miniCoverKey}
|
||||
alt={track.album}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -89,7 +89,7 @@ function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardP
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${song.album} Cover`}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
|
||||
+20
-16
@@ -1,8 +1,9 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import SongCard from './SongCard';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -36,6 +37,7 @@ export default function SongRail({
|
||||
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
||||
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const uniqueSongs = useMemo(() => dedupeById(songs), [songs]);
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
||||
@@ -51,29 +53,30 @@ export default function SongRail({
|
||||
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
|
||||
const step = Math.max(1, cardW + gap);
|
||||
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
||||
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
|
||||
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
|
||||
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (interactivityDisabled) return;
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
recomputeArtworkBudget();
|
||||
|
||||
if (!interactivityDisabled) {
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (interactivityDisabled) return;
|
||||
handleScroll();
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
// One post-layout pass ensures we account for final grid/card geometry.
|
||||
recomputeArtworkBudget();
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
});
|
||||
window.addEventListener('resize', handleScroll);
|
||||
const ro = new ResizeObserver(() => {
|
||||
recomputeArtworkBudget();
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
});
|
||||
if (scrollRef.current) ro.observe(scrollRef.current);
|
||||
return () => {
|
||||
@@ -81,11 +84,12 @@ export default function SongRail({
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [songs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||
|
||||
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
|
||||
useEffect(() => {
|
||||
setArtworkBudget(initialArtworkBudget);
|
||||
}, [initialArtworkBudget, songs.length]);
|
||||
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -94,7 +98,7 @@ export default function SongRail({
|
||||
};
|
||||
|
||||
// Hide rail entirely if empty and no empty-state copy
|
||||
if (songs.length === 0 && !loading && !emptyText) return null;
|
||||
if (uniqueSongs.length === 0 && !loading && !emptyText) return null;
|
||||
|
||||
return (
|
||||
<section className="song-row-section">
|
||||
@@ -135,11 +139,11 @@ export default function SongRail({
|
||||
</div>
|
||||
|
||||
<div className="song-grid-wrapper">
|
||||
{songs.length === 0 && emptyText ? (
|
||||
{uniqueSongs.length === 0 && emptyText ? (
|
||||
<p className="song-row-empty">{emptyText}</p>
|
||||
) : (
|
||||
<div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
|
||||
{songs.map((s, idx) => (
|
||||
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{uniqueSongs.map((s, idx) => (
|
||||
<SongCard
|
||||
key={s.id}
|
||||
song={s}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRefElementClientHeight } from '../hooks/useResizeClientHeight';
|
||||
import { Search as SearchIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
@@ -43,6 +44,8 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
const [browseUnsupported, setBrowseUnsupported] = useState(false);
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null);
|
||||
const scrollParentHeight = useRefElementClientHeight(scrollParentRef);
|
||||
const songListOverscan = Math.max(8, Math.ceil(scrollParentHeight / ROW_HEIGHT));
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
// Debounce query
|
||||
@@ -139,7 +142,7 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
count: songs.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 8,
|
||||
overscan: songListOverscan,
|
||||
});
|
||||
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
|
||||
Reference in New Issue
Block a user