Files
Psychotoxical-psysonic/src/components/nowPlaying/Hero.tsx
T
Frank Stellmacher 2ddc2a2345 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.
2026-05-13 14:21:31 +02:00

164 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
import LastfmIcon from '../LastfmIcon';
import { formatTime } from '../../utils/nowPlayingHelpers';
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;
}
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>
);
}
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>
);
});
export default Hero;