import { uploadArtistImage } from '../api/subsonicPlaylists'; import { useCoverArt } from '../cover/useCoverArt'; import { useArtistCoverRef } from '../cover/useLibraryCoverRef'; import { setRating, star, unstar } from '../api/subsonicStarRating'; import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { useEffect, useState, useRef, Fragment, useMemo } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import AlbumCard from '../components/AlbumCard'; import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines, ArrowDownUp } from 'lucide-react'; import { useIsMobile } from '../hooks/useIsMobile'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { lastfmIsConfigured } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import { invalidateCoverArt } from '../utils/imageCache'; import { showToast } from '../utils/ui/toast'; import { copyEntityShareLink } from '../utils/share/copyEntityShareLink'; import StarRating from '../components/StarRating'; import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore'; import { DEFAULT_ARTIST_ALBUM_YEAR_ORDER, useArtistAlbumYearSortStore, } from '../store/artistAlbumYearSortStore'; import { useArtistDetailData } from '../hooks/useArtistDetailData'; import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists'; import { fetchArtistDetailTracks, runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio, } from '../utils/componentHelpers/runArtistDetailPlay'; import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy'; import { runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload, } from '../utils/componentHelpers/runArtistDetailActions'; import ArtistDetailHero from '../components/artistDetail/ArtistDetailHero'; import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks'; import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists'; import ArtistCard from '../components/nowPlaying/ArtistCard'; import LosslessModeBanner from '../components/LosslessModeBanner'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX, GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes'; import { artistDetailCoverWarmAlbums } from '../components/artistDetail/topSongAlbumForCover'; import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch'; import { useWarmGridCovers } from '../hooks/useWarmGridCovers'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums'; import { readDetailServerId } from '../utils/navigation/detailServerScope'; export default function ArtistDetail() { const { t } = useTranslation(); const perfFlags = usePerfProbeFlags(); const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); const losslessOnly = searchParams.get('lossless') === '1'; const navigate = useNavigate(); const { artist, setArtist, albums, topSongs, info, featuredAlbums, loading, artistInfoLoading, featuredLoading, isStarred, setIsStarred, } = useArtistDetailData(id, { losslessOnly }); const [radioLoading, setRadioLoading] = useState(false); const [playAllLoading, setPlayAllLoading] = useState(false); const [openedLink, setOpenedLink] = useState(null); const { similarArtists, similarLoading } = useArtistSimilarArtists(artist, info, artistInfoLoading); const [uploading, setUploading] = useState(false); const [similarCollapsed, setSimilarCollapsed] = useState(true); const isMobile = useIsMobile(); const [coverRevision, setCoverRevision] = useState(0); /** True after header cover onError — avoid `display:none` on the img (breaks recovery). */ const [headerCoverFailed, setHeaderCoverFailed] = useState(false); const imageInputRef = useRef(null); const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const clearQueue = usePlayerStore(state => state.clearQueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); const currentTrack = usePlayerStore(state => state.currentTrack); const isPlaying = usePlayerStore(state => state.isPlaying); const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const authActiveServerId = useAuthStore(s => s.activeServerId); const activeServerId = readDetailServerId(searchParams, authActiveServerId) ?? ''; const audiomuseNavidromeEnabled = useAuthStore( s => !!(activeServerId && s.audiomuseNavidromeByServer[activeServerId]), ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const albumYearOrder = useArtistAlbumYearSortStore( s => s.orderByServer[activeServerId] ?? DEFAULT_ARTIST_ALBUM_YEAR_ORDER, ); const toggleAlbumYearOrder = useArtistAlbumYearSortStore(s => s.toggleYearOrder); // MUST stay above the loading / !artist early returns or React's hook // call order will mismatch between renders. const sectionConfig = useArtistLayoutStore(s => s.sections); const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown'; const offlineCtx = useOfflineBrowseContext(); const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active); const [artistEntityRating, setArtistEntityRating] = useState(0); useEffect(() => { if (!id) return; if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0); }, [id, artist?.id, artist?.userRating]); const handleArtistEntityRating = (rating: number) => runArtistEntityRating({ artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist, }); const openLink = (url: string, key: string) => { open(url); setOpenedLink(key); setTimeout(() => setOpenedLink(null), 2500); }; const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred }); const handlePlayAll = () => runArtistDetailPlayAll({ albums, serverId: activeServerId, setPlayAllLoading, playTrack, }); const handleShuffle = () => runArtistDetailShuffle({ albums, serverId: activeServerId, setPlayAllLoading, playTrack, }); const handleStartRadio = () => { if (!artist) return; return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue }); }; const handleShareArtist = () => { if (!id || !artist) return; return runArtistShare({ artist, t }); }; const playTopSongWithContinuation = async (startIndex: number) => { if (!artist || albums.length === 0) return; setPlayAllLoading(true); try { // Get all artist tracks ordered by album and track number const allTracks = await fetchArtistDetailTracks(albums, activeServerId); // Top songs from clicked index onward const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack); // Track IDs for deduplication const topSongIds = new Set(topSongs.map(s => s.id)); // Filter remaining tracks to exclude top songs (prevent duplicates) const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id)); // Build queue: remaining top songs + rest of artist catalog const queue = [...topTracksFromIndex, ...remainingTracks]; if (queue.length > 0) { playTrack(queue[0], queue); } } finally { setPlayAllLoading(false); } }; const handleImageUpload = (e: React.ChangeEvent) => runArtistImageUpload({ e, artist, t, setUploading, setCoverRevision, }); // Cover URLs — must run every render (before early returns) or hook order breaks. const coverId = artist ? (artist.coverArt || artist.id) : ''; const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, { libraryResolve: true, }); const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' }); const groupedAlbums = useMemo(() => { if (albums.length === 0) return []; const RELEASE_TYPE_ORDER = ['album', 'ep', 'single', 'compilation', 'live', 'soundtrack', 'remix', 'other']; const defaultKey = 'album'; const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); const translateType = (tag: string) => t(`artistDetail.releaseTypes.${tag}`, { defaultValue: titleCase(tag) }); const groups = new Map(); for (const album of albums) { const key = album.releaseTypes?.length ? album.releaseTypes.map(r => r.toLowerCase()).join(' · ') : defaultKey; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(album); } const sortGroup = (group: SubsonicAlbum[]) => sortArtistAlbumsByYear(group, albumYearOrder); if (groups.size === 1 && groups.has(defaultKey)) { return [[translateType(defaultKey), sortGroup(albums)] as const]; } const sortKey = (key: string) => { const idx = RELEASE_TYPE_ORDER.indexOf(key.split(' · ')[0]); return idx >= 0 ? idx : RELEASE_TYPE_ORDER.length; }; return [...groups.entries()] .sort((a, b) => sortKey(a[0]) - sortKey(b[0]) || a[0].localeCompare(b[0])) .map(([key, group]) => [ key.split(' · ').map(translateType).join(' · '), sortGroup(group), ] as const); }, [albums, albumYearOrder, t]); useEffect(() => { setHeaderCoverFailed(false); }, [coverId, coverRevision, id]); const artistCoverWarmAlbums = useMemo( () => artistDetailCoverWarmAlbums(topSongs, albums, GRID_COVER_WARM_LIMIT), [topSongs, albums], ); useWarmGridCovers(artistCoverWarmAlbums, COVER_DENSE_GRID_MIN_CELL_CSS_PX, { enabled: artistCoverWarmAlbums.length > 0, limit: GRID_COVER_WARM_LIMIT, surface: 'dense', }); useLibraryCoverPrefetch( [ { albums: artistCoverWarmAlbums.slice(0, 24), limit: 24, priority: 'high', surface: 'dense', }, ], [artistCoverWarmAlbums], ); if (loading) { return (
); } if (!artist) { return (
{t('artistDetail.notFound')}
); } const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`; const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({ id: sa.id, name: sa.name, albumCount: sa.albumCount, })); const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0; const showLastfmSimilar = lastfmIsConfigured() && (!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) && (similarLoading || similarArtists.length > 0); const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar; // ── User-customisable section order + visibility ──────────────────────────── // (`sectionConfig` is read at the top of the component — see comment there) const sectionHasData = (id: ArtistSectionId): boolean => { switch (id) { case 'bio': return !!info?.biography; case 'topTracks': return topSongs.length > 0; case 'similar': return showSimilarSection; case 'albums': return true; // always renders (empty state included) case 'featured': return featuredLoading || featuredAlbums.length > 0; } }; // The order the user actually sees: hidden-via-toggle and empty sections // are filtered out, so the "first rendered section gets marginTop: 0" rule // works regardless of the configured order. const renderableSectionIds = sectionConfig .filter(s => s.visible) .map(s => s.id) .filter(sectionHasData); const sectionMt = (id: ArtistSectionId) => renderableSectionIds[0] === id ? '0' : '2rem'; return (
{losslessOnly && } {/* User-reorderable sections — order + visibility configured in Settings. * Each case renders the same JSX it did pre-refactor; only `marginTop` * (now derived from the actual render order) and the outer wrapper changed. */} {renderableSectionIds.map(sectionId => { switch (sectionId) { case 'bio': return (
); case 'topTracks': return ( ); case 'similar': return ( ); case 'albums': return (

{losslessOnly ? t('artistDetail.albumsByLossless', { name: artist.name }) : t('artistDetail.albumsBy', { name: artist.name })}

{albums.length > 0 && ( )}
{albums.length > 0 ? ( groupedAlbums.length === 1 ? ( `${a.id}-${i}`} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={groupedAlbums[0][1].length} wrapClassName="album-grid-wrap album-grid-wrap--artist" warmGridCovers={albumGridWarmCovers()} renderItem={a => ( )} /> ) : groupedAlbums.map(([label, group]) => (

{label}

{group.length}
`${a.id}-${i}`} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={group.length} wrapClassName="album-grid-wrap album-grid-wrap--artist" warmGridCovers={albumGridWarmCovers()} renderItem={a => ( )} />
)) ) : (

{t('artistDetail.noAlbums')}

)}
); case 'featured': return (

{t('artistDetail.featuredOn')}

{featuredLoading ? (
{[...Array(4)].map((_, i) => (
))}
) : ( `${a.id}-${i}`} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={featuredAlbums.length} wrapClassName="album-grid-wrap album-grid-wrap--artist" wrapStyle={{ animation: 'fadeIn 0.3s ease' }} warmGridCovers={albumGridWarmCovers()} renderItem={a => } /> )} ); default: return null; } })}
); }