import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } /** Strip dangerous tags/attributes from server-provided HTML */ function sanitizeHtml(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const val = attr.value.toLowerCase().trim(); if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) { el.removeAttribute(attr.name); } }); }); return doc.body.innerHTML; } function LastfmIcon({ size = 16 }: { size?: number }) { return ( ); } export default function ArtistDetail() { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [artist, setArtist] = useState(null); const [albums, setAlbums] = useState([]); const [topSongs, setTopSongs] = useState([]); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [radioLoading, setRadioLoading] = useState(false); const [isStarred, setIsStarred] = useState(false); const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); const clearQueue = usePlayerStore(state => state.clearQueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); useEffect(() => { if (!id) return; setLoading(true); getArtist(id).then(artistData => { setArtist(artistData.artist); setAlbums(artistData.albums); setIsStarred(!!artistData.artist.starred); return Promise.all([ getArtistInfo(id).catch(() => null), getTopSongs(artistData.artist.name).catch(() => []) ]); }).then(([artistInfo, songsData]) => { if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null); if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]); setLoading(false); }).catch(err => { console.error(err); setLoading(false); }); }, [id]); const openLink = (url: string) => open(url); const toggleStar = async () => { if (!artist) return; const currentlyStarred = isStarred; setIsStarred(!currentlyStarred); try { if (currentlyStarred) await unstar(artist.id, 'artist'); else await star(artist.id, 'artist'); } catch (e) { console.error('Failed to toggle star', e); setIsStarred(currentlyStarred); } }; const handlePlayAll = () => { if (topSongs.length > 0) { clearQueue(); playTrack(topSongs[0], topSongs); } }; const handleShuffle = () => { if (topSongs.length > 0) { const shuffled = [...topSongs].sort(() => Math.random() - 0.5); clearQueue(); playTrack(shuffled[0], shuffled); } }; const handleStartRadio = async () => { if (!artist) return; setRadioLoading(true); try { const similar = await getSimilarSongs2(artist.id, 50); if (similar.length > 0) { clearQueue(); playTrack(similar[0], similar); } else { alert(t('artistDetail.noRadio')); } } catch (e) { console.error('Radio start failed', e); } finally { setRadioLoading(false); } }; if (loading) { return (
); } if (!artist) { return (
{t('artistDetail.notFound')}
); } const coverId = artist.coverArt || artist.id; const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`; return (
{coverId ? ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{artist.name}

{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
{(info?.lastFmUrl || artist.name) && (
{info?.lastFmUrl && ( )}
)}
{topSongs.length > 0 && ( <> )}
{/* Biography — sanitized HTML from server */} {info?.biography && (
)} {/* Top Songs */} {topSongs.length > 0 && ( <>

{t('artistDetail.topTracks')}

#
{t('artistDetail.trackTitle')}
{t('artistDetail.trackAlbum')}
{t('artistDetail.trackDuration')}
{topSongs.map((song, idx) => (
playTrack(song, topSongs)} onContextMenu={(e) => { e.preventDefault(); const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, }; openContextMenu(e.clientX, e.clientY, track, 'song'); }} >
{idx + 1}
{song.coverArt && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
{song.title}
{song.album}
{formatDuration(song.duration)}
))}
)} {/* Albums */}

0) ? '2rem' : '0', marginBottom: '1rem' }}> {t('artistDetail.albumsBy', { name: artist.name })}

{albums.length > 0 ? (
{albums.map(a => )}
) : (

{t('artistDetail.noAlbums')}

)}
); }