mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
9606a99efb
Seven domain-eng splits peel ~200 LOC of read endpoints out of `api/subsonic.ts`: - `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`, `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders for the four /rest endpoints we hand to the browser). - `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`, `setRating`, `probeEntityRatingSupport`. `setRating` still triggers the lazy `navidromeBrowse` cache invalidation; the same-folder lazy import path is preserved. - `subsonicSearch.ts` — `search`, `searchSongsPaged`. - `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`, `getNowPlaying`. - `subsonicAlbumInfo.ts` — `getAlbumInfo2`. - `subsonicLyrics.ts` — `getLyricsBySongId`. - `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`. 63 external call sites migrated to direct imports. Four `vi.mock` targets in the store-level tests pointed at `../api/subsonic` and were updated to the new module paths. Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
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<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>
|
|
);
|
|
}
|