refactor(now-playing): G.72 — extract Hero + ArtistCard + AlbumCard + TopSongsCard + CreditsCard (cluster) (#639)

Five-cut cluster pulling the dashboard subcards out of NowPlaying.tsx.
1119 → 711 LOC (−408). Each card was already a top-level
React.memo'd block with a clean prop interface, so this is straight
file-per-card extraction with no behaviour change.

Hero — full hero strip: cover image, title, artist/album/year/age
sub, format/codec/sample-rate/bit-depth/duration badges with the
Hi-Res chip, the favourite + Last.fm-love + lyrics action buttons,
play-count line, and the Last.fm track + artist stats rows (with
their per-row `you played N×` highlight). renderStars (5-star
inline indicator) moves inline as a private helper since only Hero
calls it.

ArtistCard — about-this-artist card: optional hero image (filtered
through isRealArtistImage so we don't render the well-known
"2a96…" placeholder), name, sanitized + clamp-with-Read-more bio,
similar-artist chip row. Owns its bioExpanded / bioOverflows state
and the useLayoutEffect that measures overflow.

AlbumCard — from-this-album card: meta line (year / track position /
total duration / play count), sliding-window tracklist anchored on
the current track (top 10 by default, or the 10 ending at the
running track if it's beyond position 10), and the show-all toggle.

TopSongsCard — top-songs-by-artist card: rank-ordered list (max 8),
each row clicks into playerStore via the onPlay callback.

CreditsCard — contributor / song-info card: role-grouped list with
i18n role label lookup (falls back to the raw role string).

NowPlaying drops the now-unused imports (useLayoutEffect, LastfmIcon,
Star, MicVocal, Heart, Headphones, TrendingUp from lucide,
sanitizeHtml + isRealArtistImage + formatTotalDuration from
nowPlayingHelpers) plus the inline renderStars helper. Pure code
move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 14:21:31 +02:00
committed by GitHub
parent 1c34cc04c7
commit 2ddc2a2345
6 changed files with 453 additions and 413 deletions
+9 -413
View File
@@ -3,10 +3,10 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { getSong, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicSong, SubsonicArtistInfo, SubsonicAlbum } from '../api/subsonicTypes';
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
import { Music, ExternalLink, Cast, Users, Radio, Clock, SkipForward, Info, Calendar, Disc3, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -21,7 +21,6 @@ import {
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import { useCachedUrl } from '../components/CachedImage';
import CachedImage from '../components/CachedImage';
import LastfmIcon from '../components/LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import OverlayScrollArea from '../components/OverlayScrollArea';
@@ -33,28 +32,19 @@ import {
// ─── Helpers ──────────────────────────────────────────────────────────────────
import {
formatTime, formatCompact, formatTotalDuration, sanitizeHtml, isoToParts,
buildContributorRows, isRealArtistImage,
formatTime, formatCompact, isoToParts,
buildContributorRows,
type ContributorRow,
} from '../utils/nowPlayingHelpers';
import { makeCache } from '../utils/nowPlayingCache';
import NpCardWrap from '../components/nowPlaying/NpCardWrap';
import NpColumnEl from '../components/nowPlaying/NpColumnEl';
import RadioView from '../components/nowPlaying/RadioView';
function renderStars(rating?: number) {
if (!rating) return null;
return (
<div className="np-stars-inline">
{[1, 2, 3, 4, 5].map(i => (
<Star key={i} size={13}
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--ctp-overlay1)'}
/>
))}
</div>
);
}
import Hero from '../components/nowPlaying/Hero';
import ArtistCard from '../components/nowPlaying/ArtistCard';
import AlbumCard from '../components/nowPlaying/AlbumCard';
import TopSongsCard from '../components/nowPlaying/TopSongsCard';
import CreditsCard from '../components/nowPlaying/CreditsCard';
// ─── Module-level TTL caches (shared across mounts) ───────────────────────────
@@ -69,400 +59,6 @@ const lfmArtistCache = makeCache<LastfmArtistStats | null>();
// ─── Subcomponents (all memoized) ─────────────────────────────────────────────
interface HeroProps {
track: { title: string; artist: string; album: string; year?: number;
duration: number; suffix?: string; bitRate?: number; samplingRate?: number;
bitDepth?: number; artistId?: string; albumId?: string; id: string;
userRating?: number; };
genre?: string;
playCount?: number;
userRatingOverride?: number;
lfmTrack: LastfmTrackInfo | null;
lfmArtist: LastfmArtistStats | null;
starred: boolean;
lfmLoved: boolean;
lfmLoveEnabled: boolean;
activeLyricsTab: boolean;
coverUrl: string;
onNavigate: (path: string) => void;
onToggleStar: () => void;
onToggleLfmLove: () => void;
onOpenLyrics: () => void;
}
const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverUrl, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
const { t } = useTranslation();
const rating = userRatingOverride ?? track.userRating;
const hiRes = (track.bitDepth && track.bitDepth > 16) || (track.samplingRate && track.samplingRate > 48000);
const releaseAge = track.year ? new Date().getFullYear() - track.year : 0;
return (
<div className="np-dash-hero">
<div className="np-dash-hero-cover">
{coverUrl
? <img src={coverUrl} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>}
</div>
<div className="np-dash-hero-body">
<div className="np-dash-hero-title">{track.title}</div>
<div className="np-dash-hero-sub">
<span className="np-link"
onClick={() => track.artistId && onNavigate(`/artist/${track.artistId}`)}
style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
{track.artist}
</span>
<span className="np-sep">·</span>
<span className="np-link"
onClick={() => track.albumId && onNavigate(`/album/${track.albumId}`)}
style={{ cursor: track.albumId ? 'pointer' : 'default' }}>
{track.album}
</span>
{track.year && <><span className="np-sep">·</span><span>{track.year}</span></>}
{releaseAge > 0 && (
<><span className="np-sep">·</span>
<span className="np-dash-hero-age">
{t('nowPlaying.releasedYearsAgo', { count: releaseAge, defaultValue: '{{count}} years ago' })}
</span></>
)}
</div>
<div className="np-dash-hero-badges">
{genre && <span className="np-badge">{genre}</span>}
{track.suffix && <span className="np-badge">{track.suffix.toUpperCase()}</span>}
{track.bitRate && <span className="np-badge">{track.bitRate} kbps</span>}
{track.samplingRate && <span className="np-badge">{(track.samplingRate / 1000).toFixed(1)} kHz</span>}
{track.bitDepth && <span className="np-badge">{track.bitDepth}-bit</span>}
{hiRes && <span className="np-badge np-badge-hires">Hi-Res</span>}
{track.duration > 0 && <span className="np-badge">{formatTime(track.duration)}</span>}
</div>
<div className="np-dash-hero-actions">
<button onClick={onToggleStar} className="np-dash-icon-btn"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}>
<Heart size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'currentColor'} />
</button>
{lfmLoveEnabled && (
<button onClick={onToggleLfmLove}
className={`np-dash-icon-btn np-dash-lfm-btn${lfmLoved ? ' is-loved' : ''}`}
data-tooltip={lfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}>
<LastfmIcon size={18} />
</button>
)}
<button className="np-dash-icon-btn"
onClick={onOpenLyrics}
data-tooltip={t('player.lyrics')}
style={{ color: activeLyricsTab ? 'var(--accent)' : undefined }}>
<MicVocal size={18} />
</button>
{rating && renderStars(rating)}
</div>
{(playCount != null && playCount > 0) && (
<div className="np-dash-hero-stat">
<Headphones size={13} />
<span>{t('nowPlaying.playsCount', { count: playCount, defaultValue: '{{count}} plays' })}</span>
</div>
)}
{(lfmTrack || lfmArtist) && (
<div className="np-dash-hero-lfm">
<div className="np-dash-hero-lfm-heading">
<span className="np-dash-hero-lfm-badge">Last.fm</span>
</div>
{lfmTrack && (
<div className="np-dash-hero-lfm-row">
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
<span className="np-dash-hero-lfm-sep"></span>
<span>{t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
<span className="np-dash-hero-lfm-dot">·</span>
<span>{t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
{lfmTrack.userPlaycount != null && (
<>
<span className="np-dash-hero-lfm-dot">·</span>
<span className="np-dash-hero-lfm-you">
{t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
</span>
</>
)}
</div>
)}
{lfmArtist && (
<div className="np-dash-hero-lfm-row">
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
<span className="np-dash-hero-lfm-sep"></span>
<span>{t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
<span className="np-dash-hero-lfm-dot">·</span>
<span>{t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
{lfmArtist.userPlaycount != null && (
<>
<span className="np-dash-hero-lfm-dot">·</span>
<span className="np-dash-hero-lfm-you">
{t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
</span>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
);
});
interface ArtistCardProps {
artistName: string;
artistId?: string;
artistInfo: SubsonicArtistInfo | null;
onNavigate: (path: string) => void;
}
const ArtistCard = memo(function ArtistCard({ artistName, artistId, artistInfo, onNavigate }: ArtistCardProps) {
const { t } = useTranslation();
const [bioExpanded, setBioExpanded] = useState(false);
const [bioOverflows, setBioOverflows] = useState(false);
const bioRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { setBioExpanded(false); }, [artistId]);
const bioHtml = useMemo(() => artistInfo?.biography ? sanitizeHtml(artistInfo.biography) : '', [artistInfo?.biography]);
useLayoutEffect(() => {
const el = bioRef.current;
if (!el) { setBioOverflows(false); return; }
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
}, [bioHtml]);
const similar = artistInfo?.similarArtist ?? [];
const rawLarge = artistInfo?.largeImageUrl;
const rawMed = artistInfo?.mediumImageUrl;
const heroImage = isRealArtistImage(rawLarge)
? rawLarge!
: isRealArtistImage(rawMed) ? rawMed! : '';
const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
if (!bioHtml && similar.length === 0 && !heroImage) return null;
return (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
{artistId && (
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-dash-artist-body">
{heroImage && heroCacheKey && (
<CachedImage
src={heroImage}
cacheKey={heroCacheKey}
alt={artistName}
className="np-dash-artist-image"
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="np-dash-artist-text">
<div className="np-dash-artist-name">{artistName}</div>
{bioHtml && (
<>
<div
ref={bioRef}
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: bioHtml }}
/>
{(bioOverflows || bioExpanded) && (
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlayingInfo.bioReadLess', 'Show less') : t('nowPlayingInfo.bioReadMore', 'Read more')}
</button>
)}
</>
)}
</div>
</div>
{similar.length > 0 && (
<div className="np-dash-similar">
<div className="np-dash-chip-row">
{similar.slice(0, 12).map(a => (
<span key={a.id} className="np-chip"
onClick={() => a.id && onNavigate(`/artist/${a.id}`)}
data-tooltip={t('nowPlaying.goToArtist')}>
{a.name}
</span>
))}
</div>
</div>
)}
</div>
);
});
interface AlbumCardProps {
album: SubsonicAlbum | null;
songs: SubsonicSong[];
currentTrackId: string;
albumName: string;
albumId?: string;
albumYear?: number;
onNavigate: (path: string) => void;
}
const ALBUM_TRACK_LIMIT = 10;
const AlbumCard = memo(function AlbumCard({ album, songs, currentTrackId, albumName, albumId, albumYear, onNavigate }: AlbumCardProps) {
const { t } = useTranslation();
const [showAll, setShowAll] = useState(false);
useEffect(() => { setShowAll(false); }, [albumId]);
if (songs.length === 0) return null;
const totalDur = songs.reduce((sum, s) => sum + (s.duration || 0), 0);
const currentIdx = songs.findIndex(s => s.id === currentTrackId);
const position = currentIdx >= 0 ? `${currentIdx + 1} / ${songs.length}` : `${songs.length}`;
// Sliding window anchored at the current track: when the running track sits
// beyond position N, show the N tracks ending with (and including) it.
// "Show all" expands to the full list.
let visibleSongs: SubsonicSong[];
if (showAll) {
visibleSongs = songs;
} else if (currentIdx < ALBUM_TRACK_LIMIT) {
visibleSongs = songs.slice(0, ALBUM_TRACK_LIMIT);
} else {
const end = currentIdx + 1;
visibleSongs = songs.slice(end - ALBUM_TRACK_LIMIT, end);
}
const hiddenCount = Math.max(0, songs.length - visibleSongs.length);
return (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">
<Disc3 size={13} style={{ marginRight: 5, verticalAlign: '-2px' }} />
{t('nowPlaying.fromAlbum')}
</h3>
{albumId && (
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-dash-album-meta">
<span className="np-dash-album-name">{albumName}</span>
<span className="np-dash-album-stats">
{albumYear && <span>{albumYear}</span>}
{albumYear && <span className="np-sep">·</span>}
<span>{t('nowPlaying.trackPosition', { pos: position, defaultValue: 'Track {{pos}}' })}</span>
<span className="np-sep">·</span>
<span>{formatTotalDuration(totalDur)}</span>
{album?.playCount != null && album.playCount > 0 && (
<><span className="np-sep">·</span><span>{t('nowPlaying.playsCount', { count: album.playCount, defaultValue: '{{count}} plays' })}</span></>
)}
</span>
</div>
<div className="np-album-tracklist">
{visibleSongs.map(track => {
const isActive = track.id === currentTrackId;
return (
<div key={track.id}
className={`np-album-track${isActive ? ' active' : ''}`}>
<span className="np-album-track-num">
{isActive
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
: track.track ?? '—'}
</span>
<span className="np-album-track-title truncate">{track.title}</span>
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
</div>
);
})}
</div>
{songs.length > ALBUM_TRACK_LIMIT && (
<button className="np-dash-tracklist-more" onClick={() => setShowAll(v => !v)}>
{showAll
? t('nowPlaying.showLessTracks', 'Show less')
: t('nowPlaying.showMoreTracks', { defaultValue: 'Show {{count}} more', count: hiddenCount })}
</button>
)}
</div>
);
});
interface TopSongsCardProps {
artistName: string;
artistId?: string;
songs: SubsonicSong[];
currentTrackId: string;
onNavigate: (path: string) => void;
onPlay: (song: SubsonicSong) => void;
}
const TopSongsCard = memo(function TopSongsCard({ artistName, artistId, songs, currentTrackId, onNavigate, onPlay }: TopSongsCardProps) {
const { t } = useTranslation();
const top = songs.slice(0, 8);
if (top.length === 0) return null;
return (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">
<TrendingUp size={13} style={{ marginRight: 5, verticalAlign: '-2px' }} />
{t('nowPlaying.topSongs', { defaultValue: 'Most played by this artist' })}
</h3>
{artistId && (
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-dash-top-list">
{top.map((s, idx) => {
const isActive = s.id === currentTrackId;
return (
<div key={s.id}
className={`np-dash-top-row${isActive ? ' active' : ''}`}
onClick={() => onPlay(s)}
data-tooltip={t('contextMenu.playNow')}>
<span className="np-dash-top-rank">{idx + 1}</span>
<div className="np-dash-top-body">
<span className="np-dash-top-title truncate">{s.title}</span>
{s.album && <span className="np-dash-top-sub truncate">{s.album}</span>}
</div>
<span className="np-dash-top-dur">{formatTime(s.duration)}</span>
<Play size={14} className="np-dash-top-play" />
</div>
);
})}
</div>
<div className="np-dash-top-credit">{t('nowPlaying.topSongsCredit', { name: artistName, defaultValue: 'Top tracks from {{name}}' })}</div>
</div>
);
});
interface CreditsCardProps { rows: ContributorRow[]; }
const CreditsCard = memo(function CreditsCard({ rows }: CreditsCardProps) {
const { t } = useTranslation();
if (rows.length === 0) return null;
return (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlayingInfo.songInfo', 'Song info')}</h3>
</div>
<ul className="np-info-credits">
{rows.map(row => (
<li key={row.role} className="np-info-credit-row">
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
<span className="np-info-credit-names">{row.names.join(', ')}</span>
</li>
))}
</ul>
</div>
);
});
interface TourCardProps {
artistName: string;