import { useEffect, useState, useCallback, useRef } from 'react'; import { useLocation, 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 { 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 { useArtistsBrowseCatalog } from '../hooks/useArtistsBrowseCatalog'; import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll'; import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel'; import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport'; import { ArtistsGridView } from '../components/artists/ArtistsGridView'; import { ArtistsListView } from '../components/artists/ArtistsListView'; import InpageScrollSentinel from '../components/InpageScrollSentinel'; import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters'; import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore'; import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset'; import { useNavigateToArtist } from '../hooks/useNavigateToArtist'; import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore'; import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation'; import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; export default function Artists() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId ?? ''); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const scrollSnapshotRef = useRef({ scrollTop: 0, visibleCount: 0 }); const restoreVisibleCountRef = useRef( peekArtistBrowseScrollRestore(serverId)?.visibleCount, ); const { letterFilter, setLetterFilter, starredOnly, setStarredOnly, viewMode, setViewMode, } = useArtistsBrowseFilters(serverId, scrollSnapshotRef); const artistsSearchQuery = useScopedBrowseSearchQuery('artists'); const { scrollBodyEl: artistsScrollBodyEl, bindScrollBody: bindArtistsScrollBody, getScrollRoot: getArtistsScrollRoot, } = useInpageScrollViewport(); const showArtistImages = useAuthStore(s => s.showArtistImages); const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O const navigateToArtist = useNavigateToArtist(); const location = useLocation(); const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const { catalogArtists, loading: catalogLoading, catalogHasMore, catalogLoadingMore, browseMode, loadCatalogChunk, catalogLoadingRef, } = useArtistsBrowseCatalog({ serverId, indexEnabled, starredOnly, musicLibraryFilterVersion, }); const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( artistsSearchQuery, indexEnabled, serverId, ); const artists = textSearchArtists ?? catalogArtists; const loading = catalogLoading || textSearchLoading; const textSearchActive = textSearchArtists != null; /** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */ const artistBrowsePlainLayout = perfFlags.disableMainstageVirtualLists || textSearchActive || artistsSearchQuery.trim().length > 0; const { visibleCount, loadingMore: sliceLoadingMore, loadMore: sliceLoadMore, } = useClientSliceInfiniteScroll({ pageSize: PAGE_SIZE, resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId], getScrollRoot: getArtistsScrollRoot, scrollRootEl: artistsScrollBodyEl, restoreDisplayCount: restoreVisibleCountRef.current, }); // ── 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)); const { filtered, visible, hasMore, groups, letters, artistListFlatRows, } = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode }); const pendingLetterMatch = browseMode === 'slice' && !textSearchActive && !starredOnly && letterFilter !== ALL_SENTINEL && filtered.length === 0 && catalogHasMore; const gridHasMore = hasMore || (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore); const gridLoadingMore = sliceLoadingMore || catalogLoadingMore; const loadMoreRef = useRef<() => void>(() => {}); const sentinelIntersectingRef = useRef(false); const loadMoreGrid = useCallback(() => { if (hasMore) { sliceLoadMore(); return; } if (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore && !catalogLoadingRef.current) { void loadCatalogChunk(true); } }, [ hasMore, sliceLoadMore, browseMode, textSearchActive, starredOnly, catalogHasMore, loadCatalogChunk, catalogLoadingRef, ]); loadMoreRef.current = loadMoreGrid; scrollSnapshotRef.current = { scrollTop: artistsScrollBodyEl?.scrollTop ?? 0, visibleCount, }; const { isScrollRestorePending } = useArtistsBrowseScrollRestore({ serverId, scrollBodyEl: artistsScrollBodyEl, visibleCount, loading: loading || pendingLetterMatch, loadingMore: gridLoadingMore, hasMore: gridHasMore, loadMore: loadMoreGrid, }); useEffect(() => { if (isScrollRestorePending || !readArtistBrowseRestore(location.state)) return; navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); }, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]); useEffect(() => { if (!pendingLetterMatch || catalogLoadingRef.current) return; void loadCatalogChunk(true); }, [pendingLetterMatch, loadCatalogChunk, catalogLoadingRef]); useEffect(() => { if (browseMode !== 'slice' || textSearchActive || starredOnly) return; if (!sentinelIntersectingRef.current) return; if (visibleCount < filtered.length - PAGE_SIZE) return; if (!catalogHasMore || catalogLoadingRef.current) return; void loadCatalogChunk(true); }, [ browseMode, textSearchActive, starredOnly, visibleCount, filtered.length, catalogHasMore, loadCatalogChunk, catalogLoadingRef, PAGE_SIZE, ]); const bindLoadMoreSentinel = useInpageScrollSentinel({ active: gridHasMore, getScrollRoot: getArtistsScrollRoot, scrollRootEl: artistsScrollBodyEl, onIntersect: () => loadMoreRef.current(), drainSignal: gridLoadingMore, intersectingRef: sentinelIntersectingRef, }); const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [ artistsSearchQuery, letterFilter, starredOnly, viewMode, ]); const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); const artistsInpageScrollHeight = useElementClientHeightForElement( artistsScrollBodyEl, mainScrollViewportHeight, ); const getInpageScrollElement = useCallback( () => getArtistsScrollRoot() ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null), [getArtistsScrollRoot], ); const artistListOverscan = Math.max( 12, Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST), ); const artistListWrapRef = useRef(null); const artistListScrollMargin = useVirtualizerScrollMargin( artistListWrapRef, getInpageScrollElement, { active: !artistBrowsePlainLayout && viewMode === 'list', deps: [artistListFlatRows.length], }, ); const artistListVirtualizer = useVirtualizer({ count: artistBrowsePlainLayout || 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, }); const browseScrollResetKey = [ artistsSearchQuery, letterFilter, starredOnly, viewMode, serverId, musicLibraryFilterVersion, textSearchArtists?.length ?? '', textSearchArtists?.[0]?.id ?? '', ].join('\0'); useArtistsBrowseScrollReset({ scrollSnapshotRef, getScrollRoot: getArtistsScrollRoot, isScrollRestorePending, resetKey: browseScrollResetKey, viewMode, listVirtualize: !artistBrowsePlainLayout, listVirtualizer: artistListVirtualizer, }); return (

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

{textSearchLoading && (
)}
{!(selectionMode && selectedIds.size > 0) && (<> )}
{ALPHABET.map(l => ( ))}
{loading &&
} {!loading && pendingLetterMatch && (
)} {!loading && !pendingLetterMatch && viewMode === 'grid' && ( )} {!loading && !pendingLetterMatch && viewMode === 'list' && ( )} {!loading && gridHasMore && ( )} {!loading && !pendingLetterMatch && filtered.length === 0 && (
{t('artists.notFound')}
)}
{isScrollRestorePending && (
)}
); }