diff --git a/package-lock.json b/package-lock.json index 3f4c12dc..9dc1b635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.34.11", + "version": "1.34.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.34.11", + "version": "1.34.13", "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 7515dd67..1262e26d 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -13,6 +13,7 @@ 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 { SpringScroller, targetForFraction } from '../utils/springScroll'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -21,19 +22,197 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -// ─── Fullscreen lyrics overlay ──────────────────────────────────────────────── -// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh. -// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window: -// activeIdx=0 → railY=+2×slotH (line 0 at slot 2) -// activeIdx=2 → railY=0 (line 2 at center) -// activeIdx=5 → railY=-3×slotH (line 5 at slot 2) +// ─── 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 FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) { +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 springRef = 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); + + // Create/destroy the SpringScroller when the container mounts. + const setContainerRef = useCallback((el: HTMLDivElement | null) => { + (containerRef as React.MutableRefObject).current = el; + if (el) { + springRef.current = new SpringScroller(el, 0.1, 0.78); + } else { + springRef.current?.stop(); + springRef.current = null; + } + }, []); + + // Reset everything on track change. + useEffect(() => { + lineRefs.current = []; + wordRefs.current = []; + prevWord.current = { line: -1, word: -1 }; + activeIdxRef.current = -1; + setActiveIdx(-1); + springRef.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]); + + // Spring-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 || !springRef.current) return; + springRef.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(() => { + // Stop spring animation so it doesn't fight the user's scroll. + springRef.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; + + return ( +