import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Music, Star, ExternalLink } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; 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 = [12, 13, 14, 15, 16, 17, 18, 20, 22]; const size = sizePool[(h + idx * 7) % sizePool.length]; const weight = size >= 19 ? 700 : size >= 16 ? 500 : 400; const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px'; const opacity = 0.6 + ((h % 5) * 0.08); const verticals = [-10, -6, -3, 0, 4, 7, 10, -8, 3, -4, 8, -1, 5, -7, 2]; 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} ))}
))}
); } // ─── Blurred background ─────────────────────────────────────────────────────── const NpBg = memo(function NpBg({ url }: { url: string }) { const [layers, setLayers] = useState>(() => url ? [{ url, id: 0, visible: true }] : [] ); const nextId = useRef(1); useEffect(() => { if (!url) return; const id = nextId.current++; setLayers(prev => [...prev, { url, id, visible: false }]); const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30); const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [url]); return (
{layers.map(l => (
))}
); }); // ─── 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 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); // Ambilight — sample 8 zones (4 corners + 4 edge midpoints) const [ambilightColors, setAmbilightColors] = useState({ tl: '0,0,0', tc: '0,0,0', tr: '0,0,0', ml: '0,0,0', mr: '0,0,0', bl: '0,0,0', bc: '0,0,0', br: '0,0,0', }); useEffect(() => { if (!resolvedCover) return; const img = new Image(); img.onload = () => { const S = 30; const canvas = document.createElement('canvas'); canvas.width = S; canvas.height = S; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.drawImage(img, 0, 0, S, S); const data = ctx.getImageData(0, 0, S, S).data; const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75); const avg = (x0: number, y0: number, x1: number, y1: number) => { let r = 0, g = 0, b = 0, n = 0; for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { const i = (y * S + x) * 4; r += data[i]; g += data[i+1]; b += data[i+2]; n++; } return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`; }; setAmbilightColors({ tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t), ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2), bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S), }); }; img.src = resolvedCover; }, [resolvedCover]); 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 || artistInfo?.largeImageUrl) && (

{t('nowPlaying.aboutArtist')}

{currentTrack.artistId && ( )}
{artistInfo.largeImageUrl && ( {currentTrack.artist} )} {artistInfo.biography && (
)}
)} ) : (

{t('nowPlaying.nothingPlaying')}

)}
); }