refactor(artist-detail): G.85 — extract Hero + TopTracks + SimilarArtists + action utilities (cluster) (#652)

Four-cut cluster closing out the major ArtistDetail extraction.
707 → 339 LOC (−368).

ArtistDetailHero — the full hero header: back button, lightbox
trigger, avatar with hover upload overlay + camera/loader icon +
hidden file input, glow effect from extractCoverColors onLoad,
title + album count, entity-rating row, Last.fm + Wikipedia +
favourite link row, and the action button strip (play all,
shuffle, radio, share, offline cache with progress / done state).
Subscribes to useOfflineStore / useOfflineJobStore / useAuthStore
directly so the page doesn't have to thread bulk-progress through.

ArtistDetailTopTracks — the four-column tracklist with each row's
inline play-next + preview ring + track cover thumbnail + click
into playTopSongWithContinuation. Subscribes to playerStore /
previewStore / useOrbitSongRowBehavior directly.

ArtistDetailSimilarArtists — section header (with show-more toggle
on mobile), loading spinner, and the chip list (using
serverSimilarArtists vs. similarArtists depending on which path
fed it).

runArtistDetailActions — four parameterized async actions:
runArtistEntityRating (with full / track_only fallback +
saveFailed toast), runArtistToggleStar (optimistic state + revert
on error), runArtistShare (copy link + success / failure toast),
runArtistImageUpload (upload + invalidate cover-art cache + bump
revision).

ArtistDetail drops the inline definitions; formatDuration moves
into the TopTracks component. Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 17:26:05 +02:00
committed by GitHub
parent ba0bf8aa9d
commit bb0fe828bf
5 changed files with 607 additions and 361 deletions
@@ -0,0 +1,250 @@
import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, Camera, Check, ExternalLink, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
import { useOfflineStore } from '../../store/offlineStore';
import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useAuthStore } from '../../store/authStore';
import { useIsMobile } from '../../hooks/useIsMobile';
import { extractCoverColors } from '../../utils/dynamicColors';
import CachedImage from '../CachedImage';
import CoverLightbox from '../CoverLightbox';
import LastfmIcon from '../LastfmIcon';
import StarRating from '../StarRating';
interface Props {
artist: SubsonicArtist;
id: string | undefined;
albums: SubsonicAlbum[];
info: SubsonicArtistInfo | null;
isStarred: boolean;
artistEntityRating: number;
handleArtistEntityRating: (rating: number) => Promise<void>;
toggleStar: () => Promise<void>;
handlePlayAll: () => void;
handleShuffle: () => void;
handleStartRadio: () => void;
handleShareArtist: () => void;
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
playAllLoading: boolean;
radioLoading: boolean;
uploading: boolean;
openedLink: string | null;
openLink: (url: string, key: string) => void;
coverId: string;
artistCover300Src: string;
artistCover300Key: string;
artistCover2000Src: string;
coverRevision: number;
headerCoverFailed: boolean;
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
avatarGlow: string;
setAvatarGlow: React.Dispatch<React.SetStateAction<string>>;
lightboxOpen: boolean;
setLightboxOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function ArtistDetailHero({
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink,
coverId, artistCover300Src, artistCover300Key, artistCover2000Src,
coverRevision, headerCoverFailed, setHeaderCoverFailed,
avatarGlow, setAvatarGlow, lightboxOpen, setLightboxOpen,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const imageInputRef = useRef<HTMLInputElement>(null);
const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
return (
<>
<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={artistCover2000Src}
alt={artist.name}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="artist-detail-header">
<div
className="artist-detail-avatar"
style={{
position: 'relative',
boxShadow: avatarGlow ? `0 0 36px 8px ${avatarGlow.replace('rgb(', 'rgba(').replace(')', ', 0.55)')}` : undefined,
transition: 'box-shadow 0.6s ease',
}}
>
{coverId ? (
<button
className="artist-detail-avatar-btn"
onClick={() => setLightboxOpen(true)}
aria-label={`${artist.name} Bild vergrößern`}
>
{!headerCoverFailed ? (
<CachedImage
key={coverRevision}
src={artistCover300Src}
cacheKey={artistCover300Key}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
onError={() => setHeaderCoverFailed(true)}
/>
) : (
<Users size={64} color="var(--text-muted)" style={{ margin: 'auto', display: 'block' }} />
)}
</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}
data-tooltip={isMobile ? t('artistDetail.shuffle') : undefined}
>
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
{!isMobile && t('artistDetail.shuffle')}
</button>
</>
)}
<button
className="btn btn-surface"
onClick={handleStartRadio}
disabled={radioLoading}
data-tooltip={isMobile ? t('artistDetail.radio') : undefined}
>
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
</button>
{id && artist && (
<button
type="button"
className="btn btn-surface"
onClick={handleShareArtist}
aria-label={t('artistDetail.shareArtist')}
data-tooltip={t('artistDetail.shareArtist')}
>
<Share2 size={16} />
</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} />}
{!isMobile && (isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
</button>
);
})()}
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,66 @@
import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronUp } from 'lucide-react';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import { useIsMobile } from '../../hooks/useIsMobile';
interface Props {
marginTop: string;
showAudiomuseSimilar: boolean;
showLastfmSimilar: boolean;
similarLoading: boolean;
similarArtists: SubsonicArtist[];
serverSimilarArtists: SubsonicArtist[];
similarCollapsed: boolean;
setSimilarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function ArtistDetailSimilarArtists({
marginTop, showAudiomuseSimilar, showLastfmSimilar,
similarLoading, similarArtists, serverSimilarArtists,
similarCollapsed, setSimilarCollapsed,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
return (
<Fragment>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop, marginBottom: '1rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>
{t('artistDetail.similarArtists')}
</h2>
{isMobile && (() => {
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
return list.length > 5 ? (
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
</button>
) : null;
})()}
</div>
{showLastfmSimilar && 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' }}>
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
.map((a, i) => (
<button
key={`${a.id}-${i}`}
className="artist-ext-link"
onClick={() => navigate(`/artist/${a.id}`)}
>
{a.name}
</button>
))}
</div>
)}
</Fragment>
);
}
@@ -0,0 +1,111 @@
import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/songToTrack';
import { formatDuration } from '../../utils/artistDetailHelpers';
import ArtistSuggestionTrackCover from './ArtistSuggestionTrackCover';
interface Props {
topSongs: SubsonicSong[];
marginTop: string;
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
}
export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSongWithContinuation }: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
return (
<Fragment>
<h2 className="section-title" style={{ marginTop, marginBottom: '1rem' }}>
{t('artistDetail.topTracks')}
</h2>
<div className="tracklist" data-preview-loc="artist" 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}-${idx}`}
className="track-row track-row-with-actions"
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (orbitActive) { queueHint(); return; }
playTopSongWithContinuation(idx);
}}
onDoubleClick={orbitActive ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
addTrackToOrbit(song.id);
} : undefined}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
{currentTrack?.id === song.id && isPlaying ? (
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{idx + 1}</span>
)}
</div>
<div className="track-info track-info-suggestion" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
{song.coverArt && (
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
)}
<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>
</Fragment>
);
}