import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Music, Star, ExternalLink, MicVocal } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useLyricsStore } from '../store/lyricsStore'; import { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, getAlbum, getArtistInfo, SubsonicSong, SubsonicArtistInfo, } from '../api/subsonic'; import { useCachedUrl } from '../components/CachedImage'; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatTime(s: number): string { if (!s || isNaN(s)) return '0:00'; const m = Math.floor(s / 60); return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`; } 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; } function renderStars(rating?: number) { if (!rating) return null; return (
{[1, 2, 3, 4, 5].map(i => ( ))}
); } // ─── Animated EQ Bars ───────────────────────────────────────────────────────── const BAR_COUNT = 24; const EQBars = memo(function EQBars({ isPlaying }: { isPlaying: boolean }) { const barsRef = useRef<(HTMLDivElement | null)[]>([]); const heights = useRef(Array.from({ length: BAR_COUNT }, () => 0.08)); const targets = useRef(Array.from({ length: BAR_COUNT }, () => Math.random() * 0.5 + 0.1)); const speeds = useRef(Array.from({ length: BAR_COUNT }, () => 0.06 + Math.random() * 0.08)); const rafRef = useRef(); const animate = useCallback(() => { heights.current = heights.current.map((h, i) => { const t = targets.current[i]; const newH = h + (t - h) * speeds.current[i]; if (Math.abs(newH - t) < 0.015) { targets.current[i] = Math.random() * 0.88 + 0.06; speeds.current[i] = 0.05 + Math.random() * 0.10; } return newH; }); barsRef.current.forEach((bar, i) => { if (bar) bar.style.height = `${Math.round(heights.current[i] * 100)}%`; }); rafRef.current = requestAnimationFrame(animate); }, []); useEffect(() => { if (isPlaying) { rafRef.current = requestAnimationFrame(animate); } else { if (rafRef.current) cancelAnimationFrame(rafRef.current); // Settle bars to a low resting height heights.current = heights.current.map(() => 0.08); barsRef.current.forEach(bar => { if (bar) bar.style.height = '8%'; }); } return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [isPlaying, animate]); return (
{Array.from({ length: BAR_COUNT }).map((_, i) => (
{ barsRef.current[i] = el; }} /> ))}
); }); // ─── Tag Cloud ──────────────────────────────────────────────────────────────── interface TagCloudProps { similarArtists: Array<{ id: string; name: string }>; onArtistClick: (id: string) => void; } function strHash(s: string): number { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) & 0xffff; return h; } function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) { const { t } = useTranslation(); if (similarArtists.length === 0) return null; const getTagStyle = (name: string, idx: number): React.CSSProperties => { const h = strHash(name); const sizePool = [10, 11, 12, 13, 14, 15, 16]; const size = sizePool[(h + idx * 7) % sizePool.length]; const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400; const pad = size >= 15 ? '5px 10px' : '4px 8px'; const opacity = 0.6 + ((h % 5) * 0.08); const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1]; const ty = verticals[(h + idx * 4) % verticals.length]; return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` }; }; return (
{t('artistDetail.similarArtists')}
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
{row.map((a, i) => ( onArtistClick(a.id)} data-tooltip={t('nowPlaying.goToArtist')} > {a.name} ))}
))}
); } // ─── Album Tracklist ────────────────────────────────────────────────────────── interface NpTrackListProps { albumTracks: SubsonicSong[]; currentTrackId: string; album: string; albumId?: string; onNavigate: (path: string) => void; } const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, album, albumId, onNavigate }: NpTrackListProps) { const { t } = useTranslation(); if (albumTracks.length === 0) return null; return (

{t('nowPlaying.fromAlbum')}: {album}

{albumId && ( )}
{albumTracks.map(track => { const isActive = track.id === currentTrackId; return (
albumId && onNavigate(`/album/${albumId}`)} > {isActive ? : track.track ?? '—' } {track.title} {formatTime(track.duration)}
); })}
); }); // ─── Main Page ──────────────────────────────────────────────────────────────── export default function NowPlaying() { const { t } = useTranslation(); const navigate = useNavigate(); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const toggleQueue = usePlayerStore(s => s.toggleQueue); const stableNavigate = useCallback((path: string) => navigate(path), [navigate]); // Extra song metadata const [songMeta, setSongMeta] = useState(null); useEffect(() => { if (!currentTrack) { setSongMeta(null); return; } getSong(currentTrack.id).then(setSongMeta); }, [currentTrack?.id]); // Artist info (bio + similar artists) const [artistInfo, setArtistInfo] = useState(null); useEffect(() => { if (!currentTrack?.artistId) { setArtistInfo(null); return; } getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null)); }, [currentTrack?.artistId]); // Album tracks const [albumTracks, setAlbumTracks] = useState([]); useEffect(() => { if (!currentTrack?.albumId) { setAlbumTracks([]); return; } getAlbum(currentTrack.albumId).then(d => setAlbumTracks(d.songs)).catch(() => setAlbumTracks([])); }, [currentTrack?.albumId]); // Bio expand toggle const [bioExpanded, setBioExpanded] = useState(false); useEffect(() => { setBioExpanded(false); }, [currentTrack?.artistId]); // Favorite const [starred, setStarred] = useState(false); useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]); const toggleStar = async () => { if (!currentTrack) return; if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); } else { await star(currentTrack.id, 'song'); setStarred(true); } }; // Cover const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); const similarArtists = artistInfo?.similarArtist ?? []; return (
{currentTrack ? ( <> {/* ── Hero Card ── */}
{/* Left: meta info */}
{currentTrack.title}
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }} >{currentTrack.artist} · currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }} >{currentTrack.album} {currentTrack.year && <>·{currentTrack.year}}
{songMeta?.genre && {songMeta.genre}} {currentTrack.suffix && {currentTrack.suffix.toUpperCase()}} {currentTrack.bitRate && {currentTrack.bitRate} kbps} {currentTrack.duration && {formatTime(currentTrack.duration)}} {renderStars(currentTrack.userRating)}
{/* Center: cover */}
{resolvedCover ? :
}
{/* Right: tag cloud */} navigate(`/artist/${id}`)} />
{/* ── About the Artist ── */} {artistInfo?.biography && (

{t('nowPlaying.aboutArtist')}

{currentTrack.artistId && ( )}
{artistInfo.largeImageUrl && ( {currentTrack.artist} { (e.target as HTMLImageElement).style.display = 'none'; }} /> )}
)} ) : (

{t('nowPlaying.nothingPlaying')}

)}
); }