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 (
);
});
// ─── 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 && (

)}
{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')}
)}
);
}