Improved lyric syncing, fetch

- uses a somewhat more sane way to parse lyrics and teardown timeouts
- adds 'seeked' to setCurrentTime to make detecting seeks in lyric much easier
- adds ability to fetch lyrics from genius/netease (desktop only)
This commit is contained in:
Kendall Garner 2023-05-28 14:31:49 -07:00 committed by Jeff
parent 23f9bd4e9f
commit 85d2576bdc
25 changed files with 907 additions and 118 deletions

View file

@ -1,7 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCurrentStatus, useCurrentTime } from '/@/renderer/store';
import { PlayerStatus } from '/@/renderer/types';
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';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
export type SynchronizedLyricsArray = Array<[number, string]>;
@ -9,102 +19,192 @@ interface SynchronizedLyricsProps {
lyrics: SynchronizedLyricsArray;
}
const CLOSE_ENOUGH_TIME_DIFF_SEC = 0.2;
export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
const [index, setIndex] = useState(-1);
const playersRef = PlayersRef;
const status = useCurrentStatus();
const lastTimeUpdate = useRef<number>(Infinity);
const previousTimestamp = useRef<number>(0);
const playerType = usePlayerType();
const now = useCurrentTime();
const settings = useLyricsSettings();
const timeout = useRef<ReturnType<typeof setTimeout>>();
const seeked = useSeeked();
const estimateElapsedTime = useCallback(() => {
const now = new Date().getTime();
return (now - previousTimestamp.current) / 1000;
}, []);
// A reference to the timeout handler
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
const getCurrentLyric = useCallback(
(timeInMs: number) => {
for (let idx = 0; idx < lyrics.length; idx += 1) {
if (timeInMs <= lyrics[idx][0]) {
// 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<SynchronizedLyricsArray>();
// A constantly increasing value, used to tell timers that may be out of date
// whether to proceed or stop
const timerEpoch = useRef(0);
const followRef = useRef<boolean>(settings.follow);
useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout
followRef.current = settings.follow;
}, [settings.follow]);
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 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 lyrics.length - 1;
},
[lyrics],
);
const doSetNextTimeout = useCallback(
(idx: number, currentTimeMs: number) => {
if (timeout.current) {
clearTimeout(timeout.current);
}
document
.querySelector(`#lyric-${idx}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setIndex(idx);
if (idx !== lyrics.length - 1) {
const nextTimeMs = lyrics[idx + 1][0];
const nextTime = nextTimeMs - currentTimeMs;
timeout.current = setTimeout(() => {
doSetNextTimeout(idx + 1, nextTimeMs);
}, nextTime);
} else {
timeout.current = undefined;
}
},
[lyrics],
);
const handleTimeChange = useCallback(() => {
const elapsedJs = estimateElapsedTime();
const elapsedPlayer = now - lastTimeUpdate.current;
lastTimeUpdate.current = now;
previousTimestamp.current = new Date().getTime();
if (Math.abs(elapsedJs - elapsedPlayer) >= CLOSE_ENOUGH_TIME_DIFF_SEC) {
if (timeout.current) {
clearTimeout(timeout.current);
}
const currentTimeMs = now * 1000;
const idx = getCurrentLyric(currentTimeMs);
doSetNextTimeout(idx, currentTimeMs);
return activeLyrics.length - 1;
}
}, [doSetNextTimeout, estimateElapsedTime, getCurrentLyric, now]);
return -1;
};
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];
const elapsed = performance.now() - start;
lyricTimer.current = setTimeout(() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
}, nextTime - timeInMs - elapsed);
}
}, []);
useEffect(() => {
lyricRef.current = lyrics;
if (status === PlayerStatus.PLAYING) {
let rejected = false;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000);
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
if (lyricTimer.current) clearTimeout(lyricTimer.current);
};
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
useEffect(() => {
if (status !== PlayerStatus.PLAYING) {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = undefined;
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return () => {};
return;
}
if (!seeked) {
return;
}
const changeTimeout = setTimeout(() => {
handleTimeChange();
}, 100);
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return () => clearTimeout(changeTimeout);
}, [handleTimeChange, status]);
setCurrentLyric(now * 1000);
}, [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 (
<div>
<div className="synchronized-lyrics">
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
active={idx === index}
id={`lyric-${idx}`}
lyric={text}
text={text}
/>
))}
</div>