import { useCallback, useEffect, useRef } from 'react'; import { useCurrentStatus, useCurrentTime, useLyricsSettings, usePlayerType, useSeeked, } from '/@/renderer/store'; import { PlaybackType, PlayerStatus } from '/@/renderer/types'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import isElectron from 'is-electron'; import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; import { LyricOverride, SynchronizedLyricsArray } from '/@/renderer/api/types'; import styled from 'styled-components'; import { LyricSkip } from '/@/renderer/features/lyrics/lyric-skip'; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const SynchronizedLyricsContainer = styled.div` padding: 5rem 0; `; interface SynchronizedLyricsProps { lyrics: SynchronizedLyricsArray; onRemoveLyric: () => void; override: LyricOverride | null; source: string | null; } export const SynchronizedLyrics = ({ lyrics, onRemoveLyric, override, source, }: SynchronizedLyricsProps) => { const playersRef = PlayersRef; const status = useCurrentStatus(); const playerType = usePlayerType(); const now = useCurrentTime(); const settings = useLyricsSettings(); const seeked = useSeeked(); // A reference to the timeout handler const lyricTimer = useRef>(); // A reference to the lyrics. This is necessary for the // timers, which are not part of react necessarily, to always // have the most updated values const lyricRef = useRef(); // A constantly increasing value, used to tell timers that may be out of date // whether to proceed or stop const timerEpoch = useRef(0); const delayMsRef = useRef(settings.delayMs); const followRef = useRef(settings.follow); const getCurrentLyric = (timeInMs: number) => { if (lyricRef.current) { const activeLyrics = lyricRef.current; for (let idx = 0; idx < activeLyrics.length; idx += 1) { if (timeInMs <= activeLyrics[idx][0]) { return idx === 0 ? idx : idx - 1; } } return activeLyrics.length - 1; } return -1; }; const getCurrentTime = useCallback(async () => { if (isElectron() && playerType !== PlaybackType.WEB) { if (mpvPlayer) { return mpvPlayer.getCurrentTime(); } return 0; } if (playersRef.current === undefined) { return 0; } const player = (playersRef.current.player1 ?? playersRef.current.player2).getInternalPlayer(); // If it is null, this probably means we added a new song while the lyrics tab is open // and the queue was previously empty if (!player) return 0; return player.currentTime; }, [playerType, playersRef]); const setCurrentLyric = useCallback((timeInMs: number, epoch?: number, targetIndex?: number) => { const start = performance.now(); let nextEpoch: number; if (epoch === undefined) { timerEpoch.current = (timerEpoch.current + 1) % 10000; nextEpoch = timerEpoch.current; } else if (epoch !== timerEpoch.current) { return; } else { nextEpoch = epoch; } let index: number; if (targetIndex === undefined) { index = getCurrentLyric(timeInMs); } else { index = targetIndex; } // Directly modify the dom instead of using react to prevent rerender document .querySelectorAll('.synchronized-lyrics .active') .forEach((node) => node.classList.remove('active')); if (index === -1) { lyricRef.current = undefined; return; } const currentLyric = document.querySelector(`#lyric-${index}`); if (currentLyric === null) { lyricRef.current = undefined; return; } currentLyric.classList.add('active'); if (followRef.current) { currentLyric.scrollIntoView({ behavior: 'smooth', block: 'center' }); } if (index !== lyricRef.current!.length - 1) { const nextTime = lyricRef.current![index + 1][0]; const elapsed = performance.now() - start; lyricTimer.current = setTimeout(() => { setCurrentLyric(nextTime, nextEpoch, index + 1); }, nextTime - timeInMs - elapsed); } }, []); const removeLyric = useCallback(() => { onRemoveLyric(); if (lyricTimer.current) { clearTimeout(lyricTimer.current); } timerEpoch.current += 1; }, [onRemoveLyric]); useEffect(() => { // Copy the follow settings into a ref that can be accessed in the timeout followRef.current = settings.follow; }, [settings.follow]); useEffect(() => { // This handler is used to handle when lyrics change. It is in some sense the // 'primary' handler for parsing lyrics, as unlike the other callbacks, it will // ALSO remove listeners on close. Use the promisified getCurrentTime(), because // we don't want to be dependent on npw, which may not be precise lyricRef.current = lyrics; if (status === PlayerStatus.PLAYING) { let rejected = false; getCurrentTime() .then((timeInSec: number) => { if (rejected) { return false; } setCurrentLyric(timeInSec * 1000 - delayMsRef.current); return true; }) .catch(console.error); return () => { // Case 1: cleanup happens before we hear back from // the main process. In this case, when the promise resolves, ignore the result rejected = true; // Case 2: Cleanup happens after we hear back from main process but // (potentially) before the next lyric. In this case, clear the timer. // Do NOT do this for other cleanup functions, as it should only be done // when switching to a new song (or an empty one) if (lyricTimer.current) clearTimeout(lyricTimer.current); }; } return () => {}; }, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]); useEffect(() => { // This handler is used to deal with changes to the current delay. If the offset // changes, we should immediately stop the current listening set and calculate // the correct one using the new offset. Afterwards, timing can be calculated like normal const changed = delayMsRef.current !== settings.delayMs; if (!changed) { return () => {}; } if (lyricTimer.current) { clearTimeout(lyricTimer.current); } let rejected = false; delayMsRef.current = settings.delayMs; getCurrentTime() .then((timeInSec: number) => { if (rejected) { return false; } setCurrentLyric(timeInSec * 1000 - delayMsRef.current); return true; }) .catch(console.error); return () => { // In the event this ends earlier, just kill the promise. Cleanup of // timeouts is otherwise handled by another handler rejected = true; }; }, [getCurrentTime, setCurrentLyric, settings.delayMs]); useEffect(() => { // This handler is used specifically for dealing with seeking. In this case, // we assume that now is the accurate time if (status !== PlayerStatus.PLAYING) { if (lyricTimer.current) { clearTimeout(lyricTimer.current); } return; } if (!seeked) { return; } if (lyricTimer.current) { clearTimeout(lyricTimer.current); } setCurrentLyric(now * 1000 - delayMsRef.current); }, [now, seeked, setCurrentLyric, status]); useEffect(() => { // Guaranteed cleanup; stop the timer, and just in case also increment // the epoch to instruct any dangling timers to stop if (lyricTimer.current) { clearTimeout(lyricTimer.current); } timerEpoch.current += 1; }, []); return ( {source && ( )} {override && ( <> )} {lyrics.map(([, text], idx) => ( ))} ); };