import { getArtists } from '../api/subsonicArtists'; import type { SubsonicArtist } from '../api/subsonicTypes'; import { useEffect, useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react'; import StarFilterButton from '../components/StarFilterButton'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight'; import { useCardGridMetrics } from '../hooks/useCardGridMetrics'; import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer'; import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { ALL_SENTINEL, ALPHABET, ARTIST_LIST_LAST_IN_LETTER_EST, ARTIST_LIST_LETTER_ROW_EST, ARTIST_LIST_ROW_EST, } from '../utils/componentHelpers/artistsHelpers'; import { useArtistsFiltering } from '../hooks/useArtistsFiltering'; import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { fetchNetworkStarredArtists, runLocalBrowseAllArtists } from '../utils/library/browseTextSearch'; import { ArtistsGridView } from '../components/artists/ArtistsGridView'; import { ArtistsListView } from '../components/artists/ArtistsListView'; export default function Artists() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const [catalogArtists, setCatalogArtists] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL); const [starredOnly, setStarredOnly] = useState(false); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const artistsScrollBodyRef = useRef(null); const [artistsScrollBodyEl, setArtistsScrollBodyEl] = useState(null); const bindArtistsScrollBody = useCallback((el: HTMLDivElement | null) => { artistsScrollBodyRef.current = el; setArtistsScrollBodyEl(el); }, []); const getArtistsScrollRoot = useCallback(() => artistsScrollBodyRef.current, []); const showArtistImages = useAuthStore(s => s.showArtistImages); const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O const { visibleCount, loadingMore, observerTarget, } = useArtistsInfiniteScroll({ pageSize: PAGE_SIZE, resetDeps: [filter, letterFilter, starredOnly, viewMode], getScrollRoot: getArtistsScrollRoot, }); const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( filter, indexEnabled, serverId, ); const artists = textSearchArtists ?? catalogArtists; // ── Multi-selection ────────────────────────────────────────────────────── const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); }; const toggleSelect = useCallback((id: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const selectedArtists = artists.filter(a => selectedIds.has(a.id)); useEffect(() => { let cancelled = false; setLoading(true); void (async () => { try { if (starredOnly) { if (!cancelled) setCatalogArtists(await fetchNetworkStarredArtists()); return; } if (indexEnabled && serverId) { const local = await runLocalBrowseAllArtists(serverId); if (!cancelled && local != null) { setCatalogArtists(local); return; } } if (!cancelled) setCatalogArtists(await getArtists()); } catch { /* ignore */ } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]); const { filtered, visible, hasMore, groups, letters, artistListFlatRows, } = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode }); const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [ filter, letterFilter, starredOnly, viewMode, ]); const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); const artistsInpageScrollHeight = useElementClientHeightForElement( artistsScrollBodyEl, mainScrollViewportHeight, ); const getInpageScrollElement = useCallback( () => artistsScrollBodyRef.current ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null), [], ); const artistGridMeasureRef = useRef(null); const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics( artistGridMeasureRef, viewMode === 'grid', 'artist', visible.length, ); const artistVirtualRowCount = Math.max(0, Math.ceil(visible.length / Math.max(1, artistGridCols))); const artistGridOverscan = Math.max( 2, Math.ceil(artistsInpageScrollHeight / Math.max(1, artistGridRowHeightEst)), ); const artistGridScrollMargin = useVirtualizerScrollMargin( artistGridMeasureRef, getInpageScrollElement, { active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid', deps: [artistVirtualRowCount, artistGridCols], }, ); const artistGridVirtualizer = useVirtualizer({ count: perfFlags.disableMainstageVirtualLists || viewMode !== 'grid' ? 0 : artistVirtualRowCount, getScrollElement: getInpageScrollElement, estimateSize: () => artistGridRowHeightEst, overscan: artistGridOverscan, scrollMargin: artistGridScrollMargin, }); useRemeasureGridVirtualizer(artistGridVirtualizer, { active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid' && artistVirtualRowCount > 0, gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst, virtualRowCount: artistVirtualRowCount, }); const artistListOverscan = Math.max( 12, Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST), ); const artistListWrapRef = useRef(null); const artistListScrollMargin = useVirtualizerScrollMargin( artistListWrapRef, getInpageScrollElement, { active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', deps: [artistListFlatRows.length], }, ); const artistListVirtualizer = useVirtualizer({ count: perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length, getScrollElement: getInpageScrollElement, estimateSize: index => { const row = artistListFlatRows[index]; if (!row) return ARTIST_LIST_ROW_EST; if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST; return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST; }, getItemKey: index => { const row = artistListFlatRows[index]; if (!row) return index; if (row.kind === 'letter') return `letter:${row.letter}`; return `artist:${row.artist.id}`; }, overscan: artistListOverscan, scrollMargin: artistListScrollMargin, }); return (

{selectionMode && selectedIds.size > 0 ? t('artists.selectionCount', { count: selectedIds.size }) : t('artists.title')}

setFilter(e.target.value)} id="artist-filter-input" /> {textSearchLoading && (
)}
{!(selectionMode && selectedIds.size > 0) && (<> )}
{ALPHABET.map(l => ( ))}
{loading &&
} {!loading && viewMode === 'grid' && ( )} {!loading && viewMode === 'list' && ( )} {!loading && hasMore && (
{loadingMore &&
}
)} {!loading && filtered.length === 0 && (
{t('artists.notFound')}
)}
); }