import clsx from 'clsx'; import isElectron from 'is-electron'; import { useCallback, useEffect, useRef } from 'react'; import styles from './synchronized-lyrics.module.css'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { getClockNowMs } from '/@/renderer/features/player/audio-player/playback-clock'; import { useLyricsDisplaySettings, useLyricsSettings, usePlaybackType, usePlayerActions, } from '/@/renderer/store'; import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types'; import { PlayerType } from '/@/shared/types/types'; const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; const utils = isElectron() ? window.api.utils : null; const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; export interface SynchronizedLyricsProps extends Omit { lyrics: SynchronizedLyricsArray; offsetMs?: number; settingsKey?: string; style?: React.CSSProperties; translatedLyrics?: null | string; } const USER_SCROLL_PAUSE_MS = 3000; const SCROLL_CENTER_OFFSET_PX = 15; const findActiveIndex = (lines: SynchronizedLyricsArray, timeMs: number): number => { if (lines.length === 0) return -1; // Largest idx where lines[idx][0] <= timeMs. If timeMs precedes the first stamp, return 0 // so the first line is highlighted from the start (matches the prior implementation). let lo = 0; let hi = lines.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (lines[mid][0] <= timeMs) lo = mid; else hi = mid - 1; } return lo; }; const getCenteredScrollTop = (container: HTMLElement, lyric: HTMLElement) => lyric.offsetTop + lyric.clientHeight / 2 - container.clientHeight / 2 + SCROLL_CENTER_OFFSET_PX; export const SynchronizedLyrics = ({ lyrics, offsetMs, settingsKey = 'default', style, translatedLyrics, }: SynchronizedLyricsProps) => { const lyricsSettings = useLyricsSettings(); const displaySettings = useLyricsDisplaySettings(settingsKey); const playbackType = usePlaybackType(); const { mediaSeekToTimestamp } = usePlayerActions(); const settings = { alignment: lyricsSettings.alignment, fontSize: displaySettings.fontSize && displaySettings.fontSize !== 0 ? displaySettings.fontSize : 24, gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24, opacityNonActive: displaySettings.opacityNonActive, scaleNonActive: displaySettings.scaleNonActive && displaySettings.scaleNonActive !== 0 ? displaySettings.scaleNonActive : 0.95, }; const containerRef = useRef(null); const linesRef = useRef(lyrics); const offsetMsRef = useRef(offsetMs ?? 0); const followRef = useRef(lyricsSettings.follow); const activeIdxRef = useRef(-1); const userScrollingRef = useRef(false); const programmaticScrollRef = useRef(false); const userScrollTimeoutRef = useRef>(null); const ignoreScrollUntilRef = useRef(0); // Keep refs in sync with props/settings without re-creating the rAF loop useEffect(() => { linesRef.current = lyrics; activeIdxRef.current = -1; }, [lyrics]); useEffect(() => { offsetMsRef.current = offsetMs ?? 0; }, [offsetMs]); useEffect(() => { followRef.current = lyricsSettings.follow; }, [lyricsSettings.follow]); // Single rAF loop. Runs while the component is mounted; no-ops on ticks where the // active line hasn't changed, so paused/no-progress states are essentially free. useEffect(() => { let rafId: null | number = null; const applyActive = (newIdx: number) => { const container = containerRef.current; if (!container) return; const oldIdx = activeIdxRef.current; if (newIdx === oldIdx) return; if (oldIdx !== -1) { container .querySelector(`[data-lyric-index="${oldIdx}"]`) ?.classList.remove('active'); } if (newIdx >= 0) { const node = container.querySelector(`[data-lyric-index="${newIdx}"]`); if (node) { node.classList.add('active'); if (followRef.current && !userScrollingRef.current) { programmaticScrollRef.current = true; ignoreScrollUntilRef.current = performance.now() + 50; container.scrollTop = getCenteredScrollTop(container, node); requestAnimationFrame(() => { programmaticScrollRef.current = false; }); } } } activeIdxRef.current = newIdx; }; const tick = () => { const timeMs = getClockNowMs() + offsetMsRef.current; const idx = findActiveIndex(linesRef.current, timeMs); if (idx !== activeIdxRef.current) applyActive(idx); rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => { if (rafId !== null) cancelAnimationFrame(rafId); }; }, []); // User-scroll detection: pause auto-follow for 3s after the user scrolls manually useEffect(() => { const container = containerRef.current; if (!container) return; const handleScroll = () => { if (programmaticScrollRef.current || performance.now() < ignoreScrollUntilRef.current) { return; } userScrollingRef.current = true; if (userScrollTimeoutRef.current) clearTimeout(userScrollTimeoutRef.current); userScrollTimeoutRef.current = setTimeout(() => { userScrollingRef.current = false; }, USER_SCROLL_PAUSE_MS); }; container.addEventListener('scroll', handleScroll, { passive: true }); return () => { container.removeEventListener('scroll', handleScroll); if (userScrollTimeoutRef.current) clearTimeout(userScrollTimeoutRef.current); }; }, []); const handleSeek = useCallback( (timeMs: number) => { if (!Number.isFinite(timeMs) || timeMs <= 0) return; const seconds = timeMs / 1000; if (playbackType === PlayerType.LOCAL && mpvPlayer) { mpvPlayer.seekTo(seconds); } else { mpris?.updateSeek(seconds); mediaSeekToTimestamp(seconds); } }, [mediaSeekToTimestamp, playbackType], ); const showScrollbar = () => containerRef.current?.classList.remove('hide-scrollbar'); const hideScrollbar = () => containerRef.current?.classList.add('hide-scrollbar'); return (
{lyrics.map(([time, text], idx) => ( handleSeek(time)} text={ text + (translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '') } /> ))}
); };