import { searchSongsPaged } from '../api/subsonicSearch'; import type { SubsonicSong } from '../api/subsonicTypes'; 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 { 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 { 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([]); const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const [browseUnsupported, setBrowseUnsupported] = useState(false); const scrollParentRef = useRef(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 (
{title &&

{title}

}
setQuery(e.target.value)} /> {query && ( )}
{songs.length > 0 && ( {t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''} )}
{showEmptyBrowse ? (
{emptyBrowseText ?? t('tracks.browseUnsupported')}
) : ( <>
{virtualizer.getVirtualItems().map(vi => { const song = songs[vi.index]; if (!song) return null; return (
); })}
{loading && (
{t('common.loadingMore')}
)}
)}
); }