import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { getArtists } from '../api/subsonicArtists'; import type { SubsonicArtist } from '../api/subsonicTypes'; import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react'; import StarFilterButton from '../components/StarFilterButton'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import CachedImage from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; import { usePerfProbeFlags } from '../utils/perfFlags'; const ALL_SENTINEL = 'ALL'; const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; /** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */ const ARTIST_LIST_LETTER_ROW_EST = 48; const ARTIST_LIST_ROW_EST = 64; const ARTIST_LIST_LAST_IN_LETTER_EST = 88; type ArtistListFlatRow = | { kind: 'letter'; letter: string } | { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean }; // Catppuccin accent colors — one is picked deterministically from the artist name const CTP_COLORS = [ 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', 'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)', 'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)', 'var(--ctp-blue)', 'var(--ctp-lavender)', ]; function nameColor(name: string): string { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return CTP_COLORS[h % CTP_COLORS.length]; } function nameInitial(name: string): string { // \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc. const letter = name.match(/\p{L}/u)?.[0]; if (letter) return letter.toUpperCase(); const alnum = name.match(/[0-9]/)?.[0]; return alnum ?? '?'; } function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) { const color = nameColor(artist.name); const coverId = artist.coverArt || artist.id; const { coverSrc, coverKey } = useMemo( () => ({ coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '', coverKey: coverId ? coverArtCacheKey(coverId, 300) : '', }), [coverId], ); if (showImages && coverId) { return (
); } return (
{nameInitial(artist.name)}
); } function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) { const color = nameColor(artist.name); const coverId = artist.coverArt || artist.id; const { coverSrc, coverKey } = useMemo( () => ({ coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '', coverKey: coverId ? coverArtCacheKey(coverId, 64) : '', }), [coverId], ); if (showImages && coverId) { return (
); } return (
{nameInitial(artist.name)}
); } export default function Artists() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const [artists, setArtists] = 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 showArtistImages = useAuthStore(s => s.showArtistImages); const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [loadingMore, setLoadingMore] = useState(false); const observerTarget = useRef(null); const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); // ── 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 clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); }; const selectedArtists = artists.filter(a => selectedIds.has(a.id)); useEffect(() => { getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false)); }, [musicLibraryFilterVersion]); const loadMore = useCallback(() => { if (loadingMore) return; setLoadingMore(true); setVisibleCount(prev => prev + PAGE_SIZE); setTimeout(() => setLoadingMore(false), 100); }, [loadingMore, PAGE_SIZE]); // Reset infinite scroll when filters or image setting change useEffect(() => { setVisibleCount(PAGE_SIZE); }, [filter, letterFilter, starredOnly, viewMode, PAGE_SIZE]); const starredOverrides = usePlayerStore(s => s.starredOverrides); // Filter pipeline — memoised so unrelated state changes (selection mode, // viewMode, etc.) don't re-iterate the full artists array. With 5000+ // artists each re-render walked the list twice without this. const filtered = useMemo(() => { let out = artists; if (letterFilter !== ALL_SENTINEL) { out = out.filter(a => { const first = a.name[0]?.toUpperCase() ?? '#'; const isAlpha = /^[A-Z]$/.test(first); if (letterFilter === '#') return !isAlpha; return first === letterFilter; }); } if (filter) { const needle = filter.toLowerCase(); out = out.filter(a => a.name.toLowerCase().includes(needle)); } if (starredOnly) { out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred); } return out; }, [artists, letterFilter, filter, starredOnly, starredOverrides]); const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]); const hasMore = visibleCount < filtered.length; // Intersection Observer for infinite scroll (after hasMore declaration) useEffect(() => { const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) loadMore(); }, { rootMargin: '200px' } ); if (observerTarget.current) observer.observe(observerTarget.current); return () => observer.disconnect(); }, [loadMore, hasMore]); // Group by first letter (for list view) — only recompute when the visible // slice or the view mode actually changes. Skipped entirely in grid view. const { groups, letters } = useMemo(() => { if (viewMode !== 'list') return { groups: {} as Record, letters: [] as string[] }; const g: Record = {}; for (const a of visible) { const letter = a.name[0]?.toUpperCase() ?? '#'; const key = /^[A-Z]$/.test(letter) ? letter : '#'; if (!g[key]) g[key] = []; g[key].push(a); } return { groups: g, letters: Object.keys(g).sort() }; }, [visible, viewMode]); const artistListFlatRows = useMemo((): ArtistListFlatRow[] => { if (viewMode !== 'list') return []; const out: ArtistListFlatRow[] = []; for (const letter of letters) { out.push({ kind: 'letter', letter }); const group = groups[letter]; for (let i = 0; i < group.length; i++) { out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 }); } } return out; }, [viewMode, letters, groups]); const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); /** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */ const artistListOverscan = Math.max( 12, Math.ceil(mainScrollViewportHeight / ARTIST_LIST_ROW_EST), ); const artistListVirtualizer = useVirtualizer({ count: perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length, getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), 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; }, /** Stable keys — avoids row DOM reuse glitches when the filtered slice changes. */ 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, }); return (

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

setFilter(e.target.value)} id="artist-filter-input" />
{!(selectionMode && selectedIds.size > 0) && (<> )}
{ALPHABET.map(l => ( ))}
{loading &&
} {!loading && viewMode === 'grid' && (
{visible.map(artist => (
{ if (selectionMode) { toggleSelect(artist.id); } else { navigate(`/artist/${artist.id}`); } }} onContextMenu={(e) => { e.preventDefault(); if (selectionMode && selectedIds.size > 0) { openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist'); } else { openContextMenu(e.clientX, e.clientY, artist, 'artist'); } }} style={selectionMode && selectedIds.has(artist.id) ? { outline: '2px solid var(--accent)', outlineOffset: '2px', borderRadius: 'var(--radius-md)' } : {}} > {selectionMode && (
{selectedIds.has(artist.id) && }
)}
{artist.name}
{artist.albumCount != null && (
{t('artists.albumCount', { count: artist.albumCount })}
)}
))}
)} {!loading && viewMode === 'list' && ( perfFlags.disableMainstageVirtualLists ? ( <> {letters.map(letter => (

{letter}

{groups[letter].map(artist => ( ))}
))} ) : (
{artistListVirtualizer.getVirtualItems().map(vi => { const row = artistListFlatRows[vi.index]; if (!row) return null; if (row.kind === 'letter') { return (

{row.letter}

); } const artist = row.artist; return (
); })}
) )} {!loading && hasMore && (
{loadingMore &&
}
)} {!loading && filtered.length === 0 && (
{t('artists.notFound')}
)}
); }