mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
9d30285ff1
* 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.
224 lines
7.4 KiB
TypeScript
224 lines
7.4 KiB
TypeScript
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';
|
|
import { SubsonicSong, searchSongsPaged } from '../api/subsonic';
|
|
import { ndListSongs } from '../api/navidromeBrowse';
|
|
import SongRow, { SongListHeader } from './SongRow';
|
|
|
|
const PAGE_SIZE = 50;
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
const ROW_HEIGHT = 52;
|
|
const PREFETCH_PX = 600;
|
|
|
|
/**
|
|
* Empty query → Navidrome /api/song sorted by title (no Subsonic equivalent).
|
|
* Non-empty → Subsonic search3 (search isn't a browse).
|
|
* Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3.
|
|
*/
|
|
async function fetchSongPage(query: string, offset: number): Promise<SubsonicSong[]> {
|
|
if (query !== '') {
|
|
return searchSongsPaged(query, PAGE_SIZE, offset);
|
|
}
|
|
try {
|
|
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
|
|
} catch {
|
|
return searchSongsPaged('', PAGE_SIZE, offset);
|
|
}
|
|
}
|
|
|
|
interface Props {
|
|
title?: string;
|
|
emptyBrowseText?: string;
|
|
}
|
|
|
|
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
|
const { t } = useTranslation();
|
|
const [query, setQuery] = useState('');
|
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
|
const [offset, setOffset] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
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
|
|
useEffect(() => {
|
|
const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
|
|
return () => clearTimeout(h);
|
|
}, [query]);
|
|
|
|
// Reset + first-page fetch on query change. One effect, no dep cascade,
|
|
// and a `cancelled` flag so a fast typist doesn't see results from stale queries.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setSongs([]);
|
|
setOffset(0);
|
|
setHasMore(true);
|
|
setBrowseUnsupported(false);
|
|
if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0;
|
|
|
|
const seq = ++requestSeqRef.current;
|
|
setLoading(true);
|
|
(async () => {
|
|
try {
|
|
const page = await fetchSongPage(debouncedQuery, 0);
|
|
if (cancelled || seq !== requestSeqRef.current) return;
|
|
if (page.length === 0) {
|
|
setHasMore(false);
|
|
if (debouncedQuery === '') setBrowseUnsupported(true);
|
|
} else {
|
|
setSongs(page);
|
|
setOffset(page.length);
|
|
if (page.length < PAGE_SIZE) setHasMore(false);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setHasMore(false);
|
|
} finally {
|
|
if (!cancelled && seq === requestSeqRef.current) setLoading(false);
|
|
}
|
|
})();
|
|
|
|
return () => { cancelled = true; };
|
|
}, [debouncedQuery]);
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (loading || !hasMore) return;
|
|
setLoading(true);
|
|
const seq = ++requestSeqRef.current;
|
|
try {
|
|
const page = await fetchSongPage(debouncedQuery, offset);
|
|
if (seq !== requestSeqRef.current) return;
|
|
if (page.length === 0) {
|
|
setHasMore(false);
|
|
} else {
|
|
setSongs(prev => {
|
|
const seen = new Set(prev.map(s => s.id));
|
|
const merged = [...prev];
|
|
for (const s of page) if (!seen.has(s.id)) merged.push(s);
|
|
return merged;
|
|
});
|
|
setOffset(o => o + page.length);
|
|
if (page.length < PAGE_SIZE) setHasMore(false);
|
|
}
|
|
} catch {
|
|
setHasMore(false);
|
|
} finally {
|
|
if (seq === requestSeqRef.current) setLoading(false);
|
|
}
|
|
}, [loading, hasMore, debouncedQuery, offset]);
|
|
|
|
// Scroll-based prefetch — uses ref so a stale loadMore can't loop
|
|
const loadMoreRef = useRef(loadMore);
|
|
useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]);
|
|
|
|
useEffect(() => {
|
|
const el = scrollParentRef.current;
|
|
if (!el) return;
|
|
let rafId = 0;
|
|
const onScroll = () => {
|
|
if (rafId) return;
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = 0;
|
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) {
|
|
loadMoreRef.current();
|
|
}
|
|
});
|
|
};
|
|
el.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => {
|
|
el.removeEventListener('scroll', onScroll);
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
};
|
|
}, []);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: songs.length,
|
|
getScrollElement: () => scrollParentRef.current,
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: songListOverscan,
|
|
});
|
|
|
|
const totalSize = virtualizer.getTotalSize();
|
|
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
|
|
|
|
return (
|
|
<section className="virtual-song-list-section">
|
|
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
|
|
<div className="virtual-song-list-toolbar">
|
|
<div className="virtual-song-list-search">
|
|
<SearchIcon size={16} className="virtual-song-list-search-icon" />
|
|
<input
|
|
type="text"
|
|
className="input virtual-song-list-search-input"
|
|
placeholder={t('tracks.searchPlaceholder')}
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
/>
|
|
{query && (
|
|
<button
|
|
className="virtual-song-list-search-clear"
|
|
onClick={() => setQuery('')}
|
|
aria-label={t('search.clearLabel')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="virtual-song-list-meta">
|
|
{songs.length > 0 && (
|
|
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showEmptyBrowse ? (
|
|
<div className="virtual-song-list-empty">
|
|
{emptyBrowseText ?? t('tracks.browseUnsupported')}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<SongListHeader />
|
|
<div ref={scrollParentRef} className="virtual-song-list-scroll">
|
|
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
|
{virtualizer.getVirtualItems().map(vi => {
|
|
const song = songs[vi.index];
|
|
if (!song) return null;
|
|
return (
|
|
<div
|
|
key={vi.key}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: ROW_HEIGHT,
|
|
transform: `translateY(${vi.start}px)`,
|
|
}}
|
|
>
|
|
<SongRow
|
|
song={song}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{loading && (
|
|
<div className="virtual-song-list-loading">
|
|
<div className="spinner" style={{ width: 18, height: 18 }} />
|
|
<span>{t('common.loadingMore')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|