import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal, Moon, Sunrise, } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; import { useCachedUrl } from './CachedImage'; import { getCachedBlob } from '../utils/imageCache'; import { extractCoverColors } from '../utils/dynamicColors'; import { useTranslation } from 'react-i18next'; import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics'; import { useAuthStore } from '../store/authStore'; import type { LrcLine } from '../api/lrclib'; import type { Track } from '../store/playerStore'; import { EaseScroller, targetForFraction } from '../utils/easeScroll'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; 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')}`; } // ─── Apple Music-style fullscreen lyrics ───────────────────────────────────── // Full-screen scrollable list. Active line auto-scrolls to ~35% from top. // Word-sync runs imperatively (no React re-renders on every time tick). // User scroll pauses auto-scroll for 4 s then resumes. const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTrack: Track | null }) { const { syncedLines, wordLines, plainLyrics, loading } = useLyrics(currentTrack); const staticOnly = useAuthStore(s => s.lyricsStaticOnly); const useWords = !staticOnly && wordLines !== null && wordLines.length > 0; const lineSrc: LrcLine[] | null = useWords ? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text })) : (syncedLines as LrcLine[] | null); const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0; const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seek = usePlayerStore(s => s.seek); const linesRef = useRef([]); linesRef.current = hasSynced ? lineSrc! : []; // React state only for the active line index — changes are infrequent. const [activeIdx, setActiveIdx] = useState(-1); const activeIdxRef = useRef(-1); const containerRef = useRef(null); const scrollerRef = useRef(null); const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const wordRefs = useRef([]); const prevWord = useRef({ line: -1, word: -1 }); const isUserScroll = useRef(false); const scrollTimer = useRef | null>(null); const setContainerRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el; scrollerRef.current?.stop(); scrollerRef.current = el ? new EaseScroller(el) : null; }, []); // Reset everything on track change. useEffect(() => { lineRefs.current = []; wordRefs.current = []; prevWord.current = { line: -1, word: -1 }; activeIdxRef.current = -1; setActiveIdx(-1); scrollerRef.current?.jump(0); }, [currentTrack?.id]); // Subscribe to playback time — only triggers React setState when line changes. useEffect(() => { if (!hasSynced) return; const apply = (time: number) => { const ls = linesRef.current; if (!ls.length) return; const idx = ls.reduce((acc, line, i) => time >= line.time ? i : acc, -1); if (idx !== activeIdxRef.current) { activeIdxRef.current = idx; setActiveIdx(idx); } }; apply(usePlayerStore.getState().currentTime); return usePlayerStore.subscribe(s => apply(s.currentTime)); }, [hasSynced, currentTrack?.id]); // Ease-scroll active line to ~35% from the top of the container. useEffect(() => { if (activeIdx < 0 || isUserScroll.current) return; const el = lineRefs.current[activeIdx]; const box = containerRef.current; if (!el || !box || !scrollerRef.current) return; scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35)); }, [activeIdx]); // Word-sync: imperative DOM updates, zero React re-renders per tick. useEffect(() => { wordRefs.current = []; prevWord.current = { line: -1, word: -1 }; }, [currentTrack?.id, useWords]); useEffect(() => { if (!useWords) return; const lines = wordLines as WordLyricsLine[]; const apply = (time: number) => { let li = -1; for (let i = 0; i < lines.length; i++) { if (time >= lines[i].time) li = i; else break; } let wi = -1; if (li >= 0) { const ws = lines[li].words; for (let j = 0; j < ws.length; j++) { if (time >= ws[j].time) wi = j; else break; } } const prev = prevWord.current; if (prev.line === li && prev.word === wi) return; if (prev.line !== li && prev.line >= 0 && wordRefs.current[prev.line]) for (const w of wordRefs.current[prev.line]) w.className = 'fsa-lyric-word'; if (li >= 0 && wordRefs.current[li]) { const ws = wordRefs.current[li]; for (let j = 0; j < ws.length; j++) ws[j].className = j < wi ? 'fsa-lyric-word played' : j === wi ? 'fsa-lyric-word active' : 'fsa-lyric-word'; } prevWord.current = { line: li, word: wi }; }; apply(usePlayerStore.getState().currentTime); return usePlayerStore.subscribe(s => apply(s.currentTime)); }, [useWords, wordLines]); const handleUserScroll = useCallback(() => { scrollerRef.current?.stop(); isUserScroll.current = true; if (scrollTimer.current) clearTimeout(scrollTimer.current); scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000); }, []); const handleClick = useCallback((e: React.MouseEvent) => { const target = (e.target as HTMLElement).closest('[data-time]'); if (!target || duration <= 0) return; seek(parseFloat(target.dataset.time!) / duration); }, [duration, seek]); if (!currentTrack || loading) return null; const isPlain = !hasSynced && !!plainLyrics; return (