mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
1c34cc04c7
commit
2ddc2a2345
@@ -0,0 +1,98 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, ExternalLink, Star } from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { formatTime, formatTotalDuration } from '../../utils/nowPlayingHelpers';
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default AlbumCard;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import type { SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
import { isRealArtistImage, sanitizeHtml } from '../../utils/nowPlayingHelpers';
|
||||
import CachedImage from '../CachedImage';
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArtistCard;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ContributorRow } from '../../utils/nowPlayingHelpers';
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreditsCard;
|
||||
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink, Play, TrendingUp } from 'lucide-react';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { formatTime } from '../../utils/nowPlayingHelpers';
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default TopSongsCard;
|
||||
+9
-413
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user