import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Play, Shuffle, Star, Music, Save, FolderOpen, Trash2, X, } from 'lucide-react'; import { usePlayerStore, Track } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, getAlbum, SubsonicPlaylist, SubsonicSong, } 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 formatDuration(secs: number): string { const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); const s = secs % 60; return h > 0 ? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` : `${m}:${s.toString().padStart(2, '0')}`; } function renderStars(rating?: number) { if (!rating) return null; return (
{[1, 2, 3, 4, 5].map(i => ( ))}
); } // ─── Blurred background (crossfade on track change) ─────────────────────────── 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 => (
))}
); }); // ─── Modals (reused from QueuePanel) ────────────────────────────────────────── function SavePlaylistModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) { const { t } = useTranslation(); const [name, setName] = useState(''); return (
e.stopPropagation()} style={{ maxWidth: 400 }}>

{t('queue.savePlaylist')}

setName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())} style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }} />
); } function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void; onLoad: (id: string) => void }) { const { t } = useTranslation(); const [playlists, setPlaylists] = useState([]); const [loading, setLoading] = useState(true); const fetch = () => { setLoading(true); getPlaylists().then(d => { setPlaylists(d); setLoading(false); }).catch(() => setLoading(false)); }; useEffect(() => { fetch(); }, []); const handleDelete = async (id: string, name: string) => { if (confirm(t('queue.deleteConfirm', { name }))) { await deletePlaylist(id); fetch(); } }; return (
e.stopPropagation()} style={{ maxWidth: 400 }}>

{t('queue.loadPlaylist')}

{loading ?

{t('queue.loading')}

: playlists.length === 0 ?

{t('queue.noPlaylists')}

: (
{playlists.map(p => (
{p.name}
))}
)}
); } // ─── Main Page ──────────────────────────────────────────────────────────────── let _dragFromIdx: number | null = null; export default function NowPlaying() { const { t } = useTranslation(); const navigate = useNavigate(); const currentTrack = usePlayerStore(s => s.currentTrack); const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const contextMenu = usePlayerStore(s => s.contextMenu); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const playTrack = usePlayerStore(s => s.playTrack); const shuffleQueue = usePlayerStore(s => s.shuffleQueue); const reorderQueue = usePlayerStore(s => s.reorderQueue); const removeTrack = usePlayerStore(s => s.removeTrack); const enqueue = usePlayerStore(s => s.enqueue); const clearQueue = usePlayerStore(s => s.clearQueue); 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 (genre) fetched via getSong const [songMeta, setSongMeta] = useState(null); useEffect(() => { if (!currentTrack) { setSongMeta(null); return; } getSong(currentTrack.id).then(setSongMeta); }, [currentTrack?.id]); // Favorite state 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 / background const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); // Queue drag-and-drop const [draggedIdx, setDraggedIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(null); const isDraggingInternalRef = useRef(false); const draggedIdxRef = useRef(null); const dragOverIdxRef = useRef(null); const queueListRef = useRef(null); const onDragStart = (e: React.DragEvent, index: number) => { isDraggingInternalRef.current = true; draggedIdxRef.current = index; _dragFromIdx = index; setDraggedIdx(index); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index })); }; const onDragEnterItem = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }; const onDragOverItem = (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; dragOverIdxRef.current = index; setDragOverIdx(index); }; const onDragEnd = () => { setDraggedIdx(null); setDragOverIdx(null); isDraggingInternalRef.current = false; draggedIdxRef.current = null; dragOverIdxRef.current = null; }; const onDropQueue = async (e: React.DragEvent) => { e.preventDefault(); isDraggingInternalRef.current = false; draggedIdxRef.current = null; dragOverIdxRef.current = null; setDraggedIdx(null); setDragOverIdx(null); let parsedData: any = null; try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch {} if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) { const fromIdx: number = parsedData?.index ?? _dragFromIdx!; _dragFromIdx = null; let toIdx = queue.length; if (queueListRef.current) { const items = queueListRef.current.querySelectorAll('[data-queue-idx]'); for (let i = 0; i < items.length; i++) { const rect = items[i].getBoundingClientRect(); if (e.clientY < rect.top + rect.height / 2) { toIdx = parseInt(items[i].dataset.queueIdx!); break; } } } if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx); return; } _dragFromIdx = null; if (!parsedData) return; if (parsedData.type === 'song') enqueue([parsedData.track]); else if (parsedData.type === 'album') { const albumData = await getAlbum(parsedData.id); const tracks: Track[] = albumData.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); enqueue(tracks); } }; // Playlist modals const [saveModalOpen, setSaveModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false); const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0); return (
{/* ── Hero ── */}
{currentTrack ? ( <> {/* Cover + glow */}
{resolvedCover && ( )} {resolvedCover ? :
}
{/* Meta */}
{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)}
) : (

{t('nowPlaying.nothingPlaying')}

)}
{/* ── Queue list ── */}

{t('queue.title')}

{queue.length > 0 && (
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {formatDuration(totalSecs)}
)}
{ e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }} onDrop={onDropQueue} > {queue.length === 0 ? (
{t('queue.emptyQueue')}
) : ( queue.map((track, idx) => { const isActive = idx === queueIndex; const isDragging = draggedIdx === idx; const isDragOver = dragOverIdx === idx; let dragStyle: React.CSSProperties = {}; if (isDragging) dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' }; else if (isDragOver && draggedIdx !== null) { dragStyle = draggedIdx > idx ? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' } : { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' }; } return (
playTrack(track, queue)} onContextMenu={e => { e.preventDefault(); usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx); }} draggable onDragStart={e => onDragStart(e, idx)} onDragEnter={e => onDragEnterItem(e)} onDragOver={e => onDragOverItem(e, idx)} onDragEnd={onDragEnd} style={dragStyle} >
{isActive ? : {idx + 1} }
{track.title}
{track.artist} · {track.album}
{formatTime(track.duration)}
); }) )}
{saveModalOpen && ( setSaveModalOpen(false)} onSave={async name => { try { await createPlaylist(name, queue.map(t => t.id)); setSaveModalOpen(false); } catch (e) { console.error(e); } }} /> )} {loadModalOpen && ( setLoadModalOpen(false)} onLoad={async id => { try { const data = await getPlaylist(id); const tracks: Track[] = data.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); } setLoadModalOpen(false); } catch (e) { console.error(e); } }} /> )}
); }