mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
3673d826b1
Extracts the song-list row into a single shared <SongRow> component used by Tracks Hub Browse, /search and /search/advanced. All three now share the same five-column layout (Play+Enqueue · Title · Artist · Album · Genre · Duration), the same enqueueAndPlay click behaviour, and the same right-click context menu / drag handler. The header row is rendered separately via <SongListHeader> (kept outside the virtualizer scroll container in the Tracks Hub so it doesn't scroll away). Both SearchResults and AdvancedSearch now infinite-scroll their song results via an IntersectionObserver sentinel near the bottom of the list (rootMargin 600 px). Pagination uses search3's songOffset; the free-text branch in AdvancedSearch keeps applying genre/year filters client-side per loaded page. Initial fetches stay at 50 (SearchResults) and 100 (AdvancedSearch) songs; subsequent pages are 50 each. Cleanup: - removed the redundant `(N)` count in the AdvancedSearch songs heading - dropped the unused `useNavigate` + `psyDrag` + per-page contextMenuSongId state in both search pages — SongRow handles those internally - renamed the row-internal CSS classes from `.virtual-song-*` to `.song-list-row-*` so they read as shared, and switched the mobile grid breakpoint accordingly Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.1 KiB
TypeScript
221 lines
7.1 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
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 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: 8,
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|