mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
import { star, unstar } from '../api/subsonicStarRating';
|
|
import { getArtist, getArtistInfo } from '@/features/artist';
|
|
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 { ArtistHeroCover } from '../cover/artistHero';
|
|
import { coverArtRef } from '../cover/ref';
|
|
import { useCoverLightboxSrc } from '../cover/lightbox';
|
|
import { ArrowLeft, Users, Heart, Feather, Share2 } from 'lucide-react';
|
|
import WikipediaIcon from '../components/WikipediaIcon';
|
|
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 { albumGridWarmCovers } from '../cover/layoutSizes';
|
|
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<SubsonicArtist | null>(null);
|
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
|
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isStarred, setIsStarred] = useState(false);
|
|
const [bioExpanded, setBioExpanded] = useState(false);
|
|
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
|
const [openedLink, setOpenedLink] = useState<string | null>(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":"<id>"}` is the only
|
|
// endpoint that walks the participants graph for non-AlbumArtist roles.
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
let cancelled = false;
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
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;
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setInfo(null);
|
|
getArtistInfo(id, { similarArtistCount: 0 })
|
|
.then(i => { if (!cancelled) setInfo(i ?? null); })
|
|
.catch(() => { if (!cancelled) setInfo(null); });
|
|
return () => { cancelled = true; };
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setHeaderCoverFailed(false);
|
|
}, [id]);
|
|
|
|
const coverId = artist?.coverArt || artist?.id || '';
|
|
const coverFallbackRef = useMemo(
|
|
() => (coverId ? coverArtRef(coverId) : null),
|
|
[coverId],
|
|
);
|
|
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverFallbackRef, {
|
|
alt: artist?.name ?? t('composerDetail.unknownComposer'),
|
|
});
|
|
|
|
const toggleStar = async () => {
|
|
if (!artist) return;
|
|
const next = !isStarred;
|
|
setIsStarred(next);
|
|
setStarredOverride(artist.id, next);
|
|
try {
|
|
const meta = {
|
|
serverId: artist.serverId,
|
|
name: artist.name,
|
|
albumCount: artist.albumCount,
|
|
};
|
|
if (next) await star(artist.id, 'artist', meta);
|
|
else await unstar(artist.id, 'artist', meta);
|
|
} 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 (
|
|
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="content-body">
|
|
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
|
|
{t('composerDetail.notFound')}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displayName = artist?.name || t('composerDetail.unknownComposer');
|
|
const wikiUrl = artist?.name
|
|
? `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`
|
|
: '';
|
|
|
|
const hasHeroImage = Boolean(
|
|
info?.largeImageUrl || info?.mediumImageUrl || coverId,
|
|
);
|
|
|
|
return (
|
|
<div className="content-body animate-fade-in">
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => navigate(-1)}
|
|
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
|
>
|
|
<ArrowLeft size={16} /> <span>{t('composerDetail.back')}</span>
|
|
</button>
|
|
|
|
{lightbox}
|
|
|
|
<div className="artist-detail-header">
|
|
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
|
{hasHeroImage && !headerCoverFailed && id ? (
|
|
<button
|
|
className="artist-detail-avatar-btn"
|
|
onClick={openLightbox}
|
|
aria-label={displayName}
|
|
>
|
|
<ArtistHeroCover
|
|
artistId={id}
|
|
artistInfo={info}
|
|
coverFallback={coverFallbackRef}
|
|
displayCssPx={300}
|
|
surface="sparse"
|
|
alt={displayName}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
onError={() => setHeaderCoverFailed(true)}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<Feather size={64} color="var(--text-muted)" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="artist-detail-meta">
|
|
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
|
{displayName}
|
|
</h1>
|
|
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<Users size={14} />
|
|
<span>{t('composerDetail.workCount', { count: albums.length })}</span>
|
|
</div>
|
|
|
|
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
{wikiUrl && (
|
|
<div className="artist-detail-links">
|
|
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')} aria-label={t('artistDetail.wikipediaTooltip')} data-tooltip={t('artistDetail.wikipediaTooltip')}>
|
|
<WikipediaIcon size={14} />
|
|
<span className="compact-btn-label">{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{artist && (
|
|
<button
|
|
className="artist-ext-link"
|
|
onClick={toggleStar}
|
|
aria-label={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
|
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
|
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
|
>
|
|
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
|
<span className="compact-btn-label">{t('artistDetail.favorite')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{artist && (
|
|
<button
|
|
type="button"
|
|
className="artist-ext-link"
|
|
onClick={handleShareComposer}
|
|
aria-label={t('composerDetail.shareComposer')}
|
|
data-tooltip={t('composerDetail.shareComposer')}
|
|
>
|
|
<Share2 size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{info?.biography && (
|
|
<div className="np-info-card artist-bio-card" style={{ marginTop: '2rem' }}>
|
|
<div className="np-card-header">
|
|
<h3 className="np-card-title">{t('composerDetail.about')}</h3>
|
|
</div>
|
|
<div className="np-artist-bio-row">
|
|
<div className="np-bio-wrap">
|
|
<div
|
|
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
|
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
|
/>
|
|
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
|
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
|
{t('composerDetail.works')}
|
|
</h2>
|
|
{albums.length === 0 ? (
|
|
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
|
{t('composerDetail.noWorks')}
|
|
</div>
|
|
) : (
|
|
<VirtualCardGrid
|
|
items={albums}
|
|
itemKey={(a, i) => `${a.id}-${i}`}
|
|
rowVariant="album"
|
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
|
layoutSignal={albums.length}
|
|
warmGridCovers={albumGridWarmCovers()}
|
|
renderItem={a => <AlbumCard album={a} />}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|