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 { genre?: string; year?: number; similarArtists: Array<{ id: string; name: string }>; onArtistClick: (id: string) => void; } function TagCloud({ genre, year, similarArtists, onArtistClick }: TagCloudProps) { const { t } = useTranslation(); const hasTags = genre || year || similarArtists.length > 0; if (!hasTags) return null; return (
{genre && {genre}} {year && {year}} {similarArtists.slice(0, 6).map(a => ( 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 => (
))}
); }); // ─── 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 setQueueVisible = usePlayerStore(s => s.setQueueVisible); // Hide queue panel while on this page, restore on leave useEffect(() => { const wasVisible = usePlayerStore.getState().isQueueVisible; if (wasVisible) setQueueVisible(false); return () => { if (wasVisible) setQueueVisible(true); }; }, [setQueueVisible]); // 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: cover + meta info */}
{resolvedCover && } {resolvedCover ? :
}
{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: EQ bars */} {/* Right: tag cloud */} navigate(`/artist/${id}`)} />
{/* ── About the Artist ── */} {(artistInfo?.biography || artistInfo?.largeImageUrl) && (

{t('nowPlaying.aboutArtist')}

{currentTrack.artistId && ( )}
{artistInfo.largeImageUrl && ( {currentTrack.artist} )} {artistInfo.biography && (
)}
)} {/* ── From this Album ── */} {albumTracks.length > 0 && (

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

{currentTrack.albumId && ( )}
{albumTracks.map(track => { const isActive = track.id === currentTrack.id; return (
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} > {isActive ? : track.track ?? '—' } {track.title} {formatTime(track.duration)}
); })}
)} ) : (

{t('nowPlaying.nothingPlaying')}

)}
); }