import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, ChevronDown, Repeat, Repeat1, Square, Music, MicVocal } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useLyricsStore } from '../store/lyricsStore'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } 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')}`; } function MarqueeTitle({ title }: { title: string }) { const containerRef = useRef(null); const textRef = useRef(null); const [scrollAmount, setScrollAmount] = useState(0); const measure = useCallback(() => { const container = containerRef.current; const text = textRef.current; if (!container || !text) return; // Temporarily make span inline-block to get its natural width text.style.display = 'inline-block'; const textWidth = text.getBoundingClientRect().width; text.style.display = ''; const overflow = textWidth - container.clientWidth; setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0); }, []); useEffect(() => { measure(); const ro = new ResizeObserver(measure); if (containerRef.current) ro.observe(containerRef.current); return () => ro.disconnect(); }, [title, measure]); return (
0 ? 'fs-title-marquee' : ''} style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}} > {title}
); } // ─── Crossfading blurred background ─────────────────────────────────────────── const FsBg = memo(function FsBg({ 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++; // Preload the image before starting the crossfade — prevents a blank flash // between the old and new layer while the browser decodes the image. 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)); }, 800); }); }; img.src = url; return () => { cancelled = true; }; }, [url]); return ( <> {layers.map(layer => (