mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
705c80ef07
Probe OpenSubsonic after connect; persist album/artist ratings via setRating when supported. Add shared StarRating (repeat same star to clear, pulse and shrink animations, hover freeze after clear). Widen tracklist duration column and bump narrow saved widths in localStorage. Use StarRating in AlbumHeader, track lists, and playlist rows.
619 lines
25 KiB
TypeScript
619 lines
25 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
|
import AlbumCard from '../components/AlbumCard';
|
|
import CachedImage from '../components/CachedImage';
|
|
import CoverLightbox from '../components/CoverLightbox';
|
|
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2 } from 'lucide-react';
|
|
import { open } from '@tauri-apps/plugin-shell';
|
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
|
import { useOfflineStore } from '../store/offlineStore';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
|
import LastfmIcon from '../components/LastfmIcon';
|
|
import { invalidateCoverArt } from '../utils/imageCache';
|
|
import { showToast } from '../utils/toast';
|
|
import StarRating from '../components/StarRating';
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
export default function ArtistDetail() {
|
|
const { t } = useTranslation();
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
|
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
|
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
|
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [radioLoading, setRadioLoading] = useState(false);
|
|
const [playAllLoading, setPlayAllLoading] = useState(false);
|
|
const [isStarred, setIsStarred] = useState(false);
|
|
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
|
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
|
const [similarLoading, setSimilarLoading] = useState(false);
|
|
const [featuredLoading, setFeaturedLoading] = useState(false);
|
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
const [bioExpanded, setBioExpanded] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [coverRevision, setCoverRevision] = useState(0);
|
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const playTrack = usePlayerStore(state => state.playTrack);
|
|
const enqueue = usePlayerStore(state => state.enqueue);
|
|
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 { downloadArtist, bulkProgress } = useOfflineStore();
|
|
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
|
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
|
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
|
|
|
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
setLoading(true);
|
|
setFeaturedAlbums([]);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
|
}, [id, artist?.id, artist?.userRating]);
|
|
|
|
const handleArtistEntityRating = async (rating: number) => {
|
|
if (!artist || artist.id !== id) return;
|
|
const artistId = artist.id;
|
|
const ratingAtStart = artist.userRating ?? 0;
|
|
|
|
setArtistEntityRating(rating);
|
|
|
|
if (artistEntityRatingSupport !== 'full') return;
|
|
|
|
try {
|
|
await setRating(artistId, rating);
|
|
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
|
} catch (err) {
|
|
setArtistEntityRating(ratingAtStart);
|
|
setEntityRatingSupport(activeServerId, 'track_only');
|
|
showToast(
|
|
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
|
4500,
|
|
'error',
|
|
);
|
|
}
|
|
};
|
|
|
|
// "Also Featured On" — loaded in background after main content renders
|
|
useEffect(() => {
|
|
if (!id || !artist) return;
|
|
const ownAlbumIds = new Set(albums.map(a => a.id));
|
|
setFeaturedLoading(true);
|
|
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
|
.catch(() => ({ songs: [], albums: [], artists: [] }))
|
|
.then(searchResults => {
|
|
const featuredSongs = (searchResults.songs ?? []).filter(
|
|
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
|
);
|
|
const albumMap = new Map<string, SubsonicAlbum>();
|
|
featuredSongs.forEach(song => {
|
|
if (!albumMap.has(song.albumId)) {
|
|
albumMap.set(song.albumId, {
|
|
id: song.albumId,
|
|
name: song.album,
|
|
artist: song.albumArtist ?? '',
|
|
artistId: '',
|
|
coverArt: song.coverArt,
|
|
songCount: 1,
|
|
duration: song.duration,
|
|
year: song.year,
|
|
});
|
|
} else {
|
|
const a = albumMap.get(song.albumId)!;
|
|
a.songCount++;
|
|
a.duration += song.duration;
|
|
}
|
|
});
|
|
setFeaturedAlbums([...albumMap.values()]);
|
|
setFeaturedLoading(false);
|
|
});
|
|
}, [artist?.id, musicLibraryFilterVersion]);
|
|
|
|
useEffect(() => {
|
|
if (!artist || !lastfmIsConfigured()) return;
|
|
setSimilarArtists([]);
|
|
setSimilarLoading(true);
|
|
lastfmGetSimilarArtists(artist.name).then(async names => {
|
|
if (names.length === 0) { setSimilarLoading(false); return; }
|
|
const results = await Promise.all(
|
|
names.slice(0, 30).map(name =>
|
|
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
|
)
|
|
);
|
|
const seen = new Set<string>([artist.id]);
|
|
const found: SubsonicArtist[] = [];
|
|
for (let i = 0; i < results.length; i++) {
|
|
const targetName = names[i].toLowerCase();
|
|
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
|
if (match && !seen.has(match.id)) {
|
|
seen.add(match.id);
|
|
found.push(match);
|
|
}
|
|
}
|
|
setSimilarArtists(found);
|
|
setSimilarLoading(false);
|
|
}).catch(() => setSimilarLoading(false));
|
|
}, [artist?.id, musicLibraryFilterVersion]);
|
|
|
|
const openLink = (url: string, key: string) => {
|
|
open(url);
|
|
setOpenedLink(key);
|
|
setTimeout(() => setOpenedLink(null), 2500);
|
|
};
|
|
|
|
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 fetchAllTracks = async () => {
|
|
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
|
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
|
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
|
};
|
|
|
|
const handlePlayAll = async () => {
|
|
if (albums.length === 0) return;
|
|
setPlayAllLoading(true);
|
|
try {
|
|
const tracks = await fetchAllTracks();
|
|
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
|
} finally {
|
|
setPlayAllLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleShuffle = async () => {
|
|
if (albums.length === 0) return;
|
|
setPlayAllLoading(true);
|
|
try {
|
|
const tracks = await fetchAllTracks();
|
|
if (tracks.length > 0) {
|
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
|
playTrack(shuffled[0], shuffled);
|
|
}
|
|
} finally {
|
|
setPlayAllLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleStartRadio = async () => {
|
|
if (!artist) return;
|
|
setRadioLoading(true);
|
|
try {
|
|
// Fire both fetches in parallel
|
|
const topPromise = getTopSongs(artist.name);
|
|
const similarPromise = getSimilarSongs2(artist.id, 50);
|
|
|
|
// Start playing as soon as top songs arrive
|
|
const top = await topPromise;
|
|
if (top.length > 0) {
|
|
const firstTrack = songToTrack(top[0]);
|
|
playTrack(firstTrack, [firstTrack]);
|
|
setRadioLoading(false);
|
|
// Enqueue remaining tracks when similar songs arrive
|
|
const similar = await similarPromise;
|
|
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
|
if (remaining.length > 0) enqueue(remaining);
|
|
} else {
|
|
// No top songs — fall back to similar
|
|
const similar = await similarPromise;
|
|
if (similar.length > 0) {
|
|
const tracks = similar.map(songToTrack);
|
|
playTrack(tracks[0], tracks);
|
|
} else {
|
|
alert(t('artistDetail.noRadio'));
|
|
}
|
|
setRadioLoading(false);
|
|
}
|
|
} catch (e) {
|
|
console.error('Radio start failed', e);
|
|
setRadioLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (!file || !artist) return;
|
|
setUploading(true);
|
|
try {
|
|
await uploadArtistImage(artist.id, file);
|
|
const coverId = artist.coverArt || artist.id;
|
|
await invalidateCoverArt(coverId);
|
|
// Also invalidate with bare artist.id in case coverArt differs
|
|
if (artist.coverArt && artist.coverArt !== artist.id) {
|
|
await invalidateCoverArt(artist.id);
|
|
}
|
|
setCoverRevision(r => r + 1);
|
|
showToast(t('artistDetail.uploadImage'));
|
|
} catch (err) {
|
|
showToast(
|
|
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
|
|
4000,
|
|
'error',
|
|
);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!artist) {
|
|
return (
|
|
<div className="content-body">
|
|
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
|
|
{t('artistDetail.notFound')}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const coverId = artist.coverArt || artist.id;
|
|
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
|
|
|
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('artistDetail.back')}</span>
|
|
</button>
|
|
|
|
{lightboxOpen && (
|
|
<CoverLightbox
|
|
src={buildCoverArtUrl(coverId, 2000)}
|
|
alt={artist.name}
|
|
onClose={() => setLightboxOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<div className="artist-detail-header">
|
|
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
|
{coverId ? (
|
|
<button
|
|
className="artist-detail-avatar-btn"
|
|
onClick={() => setLightboxOpen(true)}
|
|
aria-label={`${artist.name} Bild vergrößern`}
|
|
>
|
|
<CachedImage
|
|
key={coverRevision}
|
|
src={buildCoverArtUrl(coverId, 300)}
|
|
cacheKey={coverArtCacheKey(coverId, 300)}
|
|
alt={artist.name}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<Users size={64} color="var(--text-muted)" />
|
|
)}
|
|
{/* Upload overlay */}
|
|
<div
|
|
className="artist-avatar-upload-overlay"
|
|
onClick={e => { e.stopPropagation(); imageInputRef.current?.click(); }}
|
|
>
|
|
{uploading
|
|
? <Loader2 size={22} className="spin-slow" />
|
|
: <Camera size={22} />}
|
|
</div>
|
|
<input
|
|
ref={imageInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: 'none' }}
|
|
onChange={handleImageUpload}
|
|
/>
|
|
</div>
|
|
|
|
<div className="artist-detail-meta">
|
|
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
|
{artist.name}
|
|
</h1>
|
|
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
|
|
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
|
</div>
|
|
|
|
<div className="artist-detail-entity-rating">
|
|
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
|
<StarRating
|
|
value={artistEntityRating}
|
|
onChange={handleArtistEntityRating}
|
|
disabled={artistEntityRatingSupport === 'track_only'}
|
|
labelKey="entityRating.artistAriaLabel"
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
{(info?.lastFmUrl || artist.name) && (
|
|
<div className="artist-detail-links">
|
|
{info?.lastFmUrl && (
|
|
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
|
<LastfmIcon size={14} />
|
|
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
|
</button>
|
|
)}
|
|
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
|
<ExternalLink size={14} />
|
|
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
className="artist-ext-link"
|
|
onClick={toggleStar}
|
|
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"} />
|
|
{t('artistDetail.favorite')}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
|
{albums.length > 0 && (
|
|
<>
|
|
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
|
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
|
{t('artistDetail.playAll')}
|
|
</button>
|
|
<button className="btn btn-surface" onClick={handleShuffle} disabled={playAllLoading}>
|
|
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
|
{t('artistDetail.shuffle')}
|
|
</button>
|
|
</>
|
|
)}
|
|
<button className="btn btn-surface" onClick={handleStartRadio} disabled={radioLoading}>
|
|
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
|
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
|
|
</button>
|
|
{albums.length > 0 && (() => {
|
|
const progress = id ? bulkProgress[id] : undefined;
|
|
const isDone = progress && progress.done === progress.total;
|
|
const isDownloading = progress && !isDone;
|
|
return (
|
|
<button
|
|
className="btn btn-surface"
|
|
disabled={!!isDownloading}
|
|
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
|
data-tooltip={isDownloading
|
|
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
|
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
|
>
|
|
{isDownloading
|
|
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
|
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
|
{isDownloading
|
|
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
|
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
|
</button>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Biography — sanitized HTML from server */}
|
|
{info?.biography && (
|
|
<div className="np-info-card artist-bio-card">
|
|
<div className="np-card-header">
|
|
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
|
</div>
|
|
<div className="np-artist-bio-row">
|
|
{(info.largeImageUrl || coverId) && (
|
|
<img
|
|
src={info.largeImageUrl || buildCoverArtUrl(coverId, 80)}
|
|
alt={artist.name}
|
|
className="np-artist-thumb"
|
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
)}
|
|
<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>
|
|
)}
|
|
|
|
{/* Top Songs */}
|
|
{topSongs.length > 0 && (
|
|
<>
|
|
<h2 className="section-title" style={{ marginTop: info?.biography ? '2rem' : '0', marginBottom: '1rem' }}>
|
|
{t('artistDetail.topTracks')}
|
|
</h2>
|
|
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
|
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
|
<div style={{ textAlign: 'center' }}>#</div>
|
|
<div>{t('artistDetail.trackTitle')}</div>
|
|
<div>{t('artistDetail.trackAlbum')}</div>
|
|
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
|
|
</div>
|
|
{topSongs.map((song, idx) => {
|
|
const track = songToTrack(song);
|
|
return (
|
|
<div
|
|
key={song.id}
|
|
className="track-row"
|
|
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
|
onClick={e => {
|
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
|
playTrack(track, topSongs.map(songToTrack));
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
openContextMenu(e.clientX, e.clientY, track, 'song');
|
|
}}
|
|
>
|
|
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
|
|
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
|
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
|
<span className="track-num-number">{idx + 1}</span>
|
|
</div>
|
|
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
{song.coverArt && (
|
|
<CachedImage
|
|
src={buildCoverArtUrl(song.coverArt, 64)}
|
|
cacheKey={coverArtCacheKey(song.coverArt, 64)}
|
|
alt={song.album}
|
|
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
)}
|
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
<div className="track-title">{song.title}</div>
|
|
</div>
|
|
</div>
|
|
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
|
|
{song.album}
|
|
</div>
|
|
<div className="track-duration" style={{ textAlign: 'right' }}>
|
|
{formatDuration(song.duration)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Similar Artists (Last.fm) */}
|
|
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
|
|
<>
|
|
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
|
{t('artistDetail.similarArtists')}
|
|
</h2>
|
|
{similarLoading ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
|
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
|
{t('artistDetail.loading')}
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
{similarArtists.map(a => (
|
|
<button
|
|
key={a.id}
|
|
className="artist-ext-link"
|
|
onClick={() => navigate(`/artist/${a.id}`)}
|
|
>
|
|
{a.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Albums */}
|
|
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
|
|
{t('artistDetail.albumsBy', { name: artist.name })}
|
|
</h2>
|
|
|
|
{albums.length > 0 ? (
|
|
<div className="album-grid-wrap">
|
|
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
|
</div>
|
|
) : (
|
|
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
|
)}
|
|
|
|
{/* Also Featured On */}
|
|
{(featuredLoading || featuredAlbums.length > 0) && (
|
|
<>
|
|
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
|
{t('artistDetail.featuredOn')}
|
|
</h2>
|
|
{featuredLoading ? (
|
|
<div className="album-grid-wrap">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="album-grid-wrap" style={{ animation: 'fadeIn 0.3s ease' }}>
|
|
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|