import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { star, unstar } from '../api/subsonicStarRating'; import { getArtist, getArtistInfo } from '../api/subsonicArtists'; import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '../api/subsonicTypes'; import { useEffect, useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ndListAlbumsByArtistRole } from '../api/navidromeBrowse'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; import { ArrowLeft, Users, ExternalLink, Heart, Feather, Share2 } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { copyEntityShareLink } from '../utils/share/copyEntityShareLink'; import { showToast } from '../utils/ui/toast'; import { sanitizeHtml } from '../utils/sanitizeHtml'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; export default function ComposerDetail() { const { t } = useTranslation(); const perfFlags = usePerfProbeFlags(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [artist, setArtist] = useState(null); const [albums, setAlbums] = useState([]); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [isStarred, setIsStarred] = useState(false); const [bioExpanded, setBioExpanded] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false); const [headerCoverFailed, setHeaderCoverFailed] = useState(false); const [openedLink, setOpenedLink] = useState(null); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); // Subsonic `getArtist.view` only follows AlbumArtist relations, so for a // composer-only credit it returns the right name + bio but zero albums. // Native API `/api/album?_filters={"role_composer_id":""}` is the only // endpoint that walks the participants graph for non-AlbumArtist roles. useEffect(() => { if (!id) return; let cancelled = false; setLoading(true); Promise.all([ getArtist(id).catch(() => null), ndListAlbumsByArtistRole(id, 'composer', 0, 500).catch(err => { console.warn('[psysonic] composer albums load failed:', err); return [] as SubsonicAlbum[]; }), ]).then(([artistData, composerAlbums]) => { if (cancelled) return; if (artistData) { setArtist(artistData.artist); setIsStarred(!!artistData.artist.starred); } setAlbums(composerAlbums); setLoading(false); }); return () => { cancelled = true; }; }, [id, musicLibraryFilterVersion]); // Bio + Last.fm image — Last.fm matches by name, so well-known composers // (Bach, Mozart, Chopin) hit; obscure ones get an empty bio. Failure is // silent — we just show the initial-letter avatar instead. // Bio is library-independent (Last.fm is global), so this effect tracks // [id] only — keeping the bio visible across music-library scope changes. // The info reset lives here, not in the load effect, or a scope bump would // wipe the bio without re-fetching it. useEffect(() => { if (!id) return; let cancelled = false; setInfo(null); getArtistInfo(id, { similarArtistCount: 0 }) .then(i => { if (!cancelled) setInfo(i ?? null); }) .catch(() => { if (!cancelled) setInfo(null); }); return () => { cancelled = true; }; }, [id]); useEffect(() => { setHeaderCoverFailed(false); }, [id]); const coverId = artist?.coverArt || artist?.id || ''; const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]); const coverKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]); const coverLargeSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 2000) : '', [coverId]); const toggleStar = async () => { if (!artist) return; const next = !isStarred; setIsStarred(next); setStarredOverride(artist.id, next); try { if (next) await star(artist.id, 'artist'); else await unstar(artist.id, 'artist'); } catch (err) { console.warn('[psysonic] composer star failed:', err); setIsStarred(!next); setStarredOverride(artist.id, !next); } }; const openLink = (url: string, key: string) => { setOpenedLink(key); open(url).catch(() => {}); setTimeout(() => setOpenedLink(null), 2500); }; const handleShareComposer = async () => { if (!id || !artist) return; try { const ok = await copyEntityShareLink('composer', artist.id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } catch { showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); } }; if (loading) { return (
); } // Real not-found only when neither metadata nor works came back. If getArtist // failed but ndListAlbumsByArtistRole succeeded, render a degraded header so // a flaky Subsonic endpoint doesn't hide the works the user came here for. if (!artist && albums.length === 0) { return (
{t('composerDetail.notFound')}
); } const displayName = artist?.name || t('composerDetail.unknownComposer'); const wikiUrl = artist?.name ? `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}` : ''; // Header image source can be either Last.fm (artist-info path) or the Subsonic // cover-art endpoint. Cache key must mirror the actual URL or we'd alias both // entries under a single Subsonic key, polluting the cache between servers. // The Last.fm key is derived from the route id (same id namespace as the // SubsonicArtist record) so it stays stable even when getArtist failed and // we still render a Last.fm avatar from the bio fetch alone. const headerImageSrc = info?.largeImageUrl || coverSrc; const headerImageCacheKey = info?.largeImageUrl ? `lastfm:artist:${id}:large` : coverKey; return (
{lightboxOpen && headerImageSrc && ( setLightboxOpen(false)} /> )}
{headerImageSrc && !headerCoverFailed ? ( ) : ( )}

{displayName}

{t('composerDetail.workCount', { count: albums.length })}
{wikiUrl && (
)} {artist && ( )} {artist && ( )}
{info?.biography && (

{t('composerDetail.about')}

)}

{t('composerDetail.works')}

{albums.length === 0 ? (
{t('composerDetail.noWorks')}
) : ( `${a.id}-${i}`} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={albums.length} renderItem={a => } /> )}
); }