import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, ChevronDown, Repeat, Repeat1, Square, Music, Heart } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, '0')}`; } // ─── Artist portrait — right half, crossfades on track change ───────────────── const FsPortrait = memo(function FsPortrait({ url }: { url: string }) { const [layers, setLayers] = useState>(() => url ? [{ url, id: 0, visible: true }] : [] ); const counterRef = useRef(1); useEffect(() => { if (!url) return; let cancelled = false; const id = counterRef.current++; const img = new Image(); img.onload = img.onerror = () => { if (cancelled) return; setLayers(prev => [...prev, { url, id, visible: false }]); requestAnimationFrame(() => { if (cancelled) return; setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); setTimeout(() => { if (!cancelled) setLayers(prev => prev.filter(l => l.id === id)); }, 1000); }); }; img.src = url; return () => { cancelled = true; }; }, [url]); if (layers.length === 0) return null; return ( ); }); // ─── Full-width seekbar (isolated — re-renders every tick) ──────────────────── const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) { const progress = usePlayerStore(s => s.progress); const buffered = usePlayerStore(s => s.buffered); const currentTime = usePlayerStore(s => s.currentTime); const seek = usePlayerStore(s => s.seek); const handleSeek = useCallback( (e: React.ChangeEvent) => seek(parseFloat(e.target.value)), [seek] ); const pct = progress * 100; const buf = Math.max(pct, buffered * 100); return (
{formatTime(currentTime)} {formatTime(duration)}
); }); // ─── Play/Pause button (isolated — subscribes to isPlaying only) ────────────── const FsPlayBtn = memo(function FsPlayBtn() { const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); const togglePlay = usePlayerStore(s => s.togglePlay); return ( ); }); // ─── Main component ──────────────────────────────────────────────────────────── interface FullscreenPlayerProps { onClose: () => void; } export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const { t } = useTranslation(); const currentTrack = usePlayerStore(s => s.currentTrack); const repeatMode = usePlayerStore(s => s.repeatMode); const next = usePlayerStore(s => s.next); const previous = usePlayerStore(s => s.previous); const stop = usePlayerStore(s => s.stop); const toggleRepeat = usePlayerStore(s => s.toggleRepeat); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const isStarred = currentTrack ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; const toggleStar = useCallback(async () => { if (!currentTrack) return; const nextVal = !isStarred; setStarredOverride(currentTrack.id, nextVal); try { if (nextVal) await star(currentTrack.id, 'song'); else await unstar(currentTrack.id, 'song'); } catch { setStarredOverride(currentTrack.id, !nextVal); } }, [currentTrack, isStarred, setStarredOverride]); const duration = currentTrack?.duration ?? 0; // buildCoverArtUrl generates a new salt on every call — must be memoized. const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]); const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]); // `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl). const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false); // Artist image → portrait on right. Falls back to cover art. const [artistBgUrl, setArtistBgUrl] = useState(''); useEffect(() => { setArtistBgUrl(''); const artistId = currentTrack?.artistId; if (!artistId) return; let cancelled = false; getArtistInfo(artistId).then(info => { if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl); }).catch(() => {}); return () => { cancelled = true; }; }, [currentTrack?.artistId]); const portraitUrl = artistBgUrl || resolvedCoverUrl; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const metaParts = [ currentTrack?.album, currentTrack?.year?.toString(), currentTrack?.suffix?.toUpperCase(), currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '', ].filter(Boolean); return (
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}