import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import CachedImage from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; const ALL_SENTINEL = 'ALL'; const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; // 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); if (showImages && artist.coverArt) { return (
); } return (
{nameInitial(artist.name)}
); } function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) { const color = nameColor(artist.name); if (showImages && artist.coverArt) { return (
); } return (
{nameInitial(artist.name)}
); } export default function Artists() { const { t } = useTranslation(); const [artists, setArtists] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL); 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, viewMode, PAGE_SIZE]); // Filter pipeline let filtered = artists; if (letterFilter !== ALL_SENTINEL) { filtered = filtered.filter(a => { const first = a.name[0]?.toUpperCase() ?? '#'; const isAlpha = /^[A-Z]$/.test(first); if (letterFilter === '#') return !isAlpha; return first === letterFilter; }); } if (filter) { filtered = filtered.filter(a => a.name.toLowerCase().includes(filter.toLowerCase())); } const visible = filtered.slice(0, 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) const groups: Record = {}; visible.forEach(a => { const letter = a.name[0]?.toUpperCase() ?? '#'; const key = /^[A-Z]$/.test(letter) ? letter : '#'; if (!groups[key]) groups[key] = []; groups[key].push(a); }); const letters = Object.keys(groups).sort(); 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' && ( <> {letters.map(letter => (

{letter}

{groups[letter].map(artist => ( ))}
))} )} {!loading && hasMore && (
{loadingMore &&
}
)} {!loading && filtered.length === 0 && (
{t('artists.notFound')}
)}
); }