feishin/src/renderer/features/lyrics/synchronized-lyrics.tsx

364 lines
12 KiB
TypeScript
Raw Normal View History

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 { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import {
useCurrentPlayer,
2023-07-01 19:10:05 -07:00
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlaybackType,
usePlayerData,
2023-07-01 19:10:05 -07:00
useSeeked,
useSetCurrentTime,
} from '/@/renderer/store';
2025-05-20 19:23:36 -07:00
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
import { PlaybackType, PlayerStatus } 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;
2023-05-22 17:38:31 -07:00
2024-02-01 23:53:10 -08:00
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
2023-07-01 19:10:05 -07:00
lyrics: SynchronizedLyricsArray;
translatedLyrics?: null | string;
2023-05-22 17:38:31 -07:00
}
export const SynchronizedLyrics = ({
2023-07-01 19:10:05 -07:00
artist,
lyrics,
name,
remote,
source,
translatedLyrics,
}: SynchronizedLyricsProps) => {
2023-07-01 19:10:05 -07:00
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playbackType = usePlaybackType();
const playerData = usePlayerData();
2023-07-01 19:10:05 -07:00
const now = useCurrentTime();
const settings = useLyricsSettings();
const currentPlayer = useCurrentPlayer();
const currentPlayerRef =
currentPlayer === 1 ? playersRef.current?.player1 : playersRef.current?.player2;
const setCurrentTime = useSetCurrentTime();
const { handleScrobbleFromSeek } = useScrobble();
const handleSeek = useCallback(
(time: number) => {
if (playbackType === PlaybackType.LOCAL && mpvPlayer) {
mpvPlayer.seekTo(time);
setCurrentTime(time, true);
} else {
setCurrentTime(time, true);
handleScrobbleFromSeek(time);
mpris?.updateSeek(time);
currentPlayerRef?.seekTo(time);
}
},
[currentPlayerRef, handleScrobbleFromSeek, playbackType, setCurrentTime],
);
2023-07-01 19:10:05 -07:00
const seeked = useSeeked();
// A reference to the timeout handler
2025-05-20 19:23:36 -07:00
const lyricTimer = useRef<null | ReturnType<typeof setTimeout>>(null);
2023-07-01 19:10:05 -07:00
// 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
2025-05-20 19:23:36 -07:00
const lyricRef = useRef<null | SynchronizedLyricsArray>(null);
2023-07-01 19:10:05 -07:00
// 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;
}
2023-07-01 19:10:05 -07:00
return -1;
};
2023-07-01 19:10:05 -07:00
const getCurrentTime = useCallback(async () => {
if (isElectron() && playbackType !== PlaybackType.WEB) {
2023-07-01 19:10:05 -07:00
if (mpvPlayer) {
return mpvPlayer.getCurrentTime();
}
return 0;
}
2023-05-22 17:38:31 -07:00
2023-07-01 19:10:05 -07:00
if (playersRef.current === undefined) {
return 0;
}
2023-05-22 17:38:31 -07:00
const player =
playerData.current.player === 1
? playersRef.current.player1
: playersRef.current.player2;
const underlying = player?.getInternalPlayer();
2023-07-01 19:10:05 -07:00
// 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 (!underlying) return 0;
2023-07-01 19:10:05 -07:00
return underlying.currentTime;
}, [playbackType, playersRef, playerData]);
2023-07-01 19:10:05 -07:00
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) {
2025-05-20 19:23:36 -07:00
lyricRef.current = null;
2023-07-01 19:10:05 -07:00
return;
}
const doc = document.getElementById(
'sychronized-lyrics-scroll-container',
) as HTMLElement;
const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;
2025-05-20 19:23:36 -07:00
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 || 0;
2023-07-01 19:10:05 -07:00
if (currentLyric === null) {
2025-05-20 19:23:36 -07:00
lyricRef.current = null;
2023-07-01 19:10:05 -07:00
return;
}
currentLyric.classList.add('active');
if (followRef.current) {
doc?.scroll({ behavior: 'smooth', top: offsetTop });
}
if (index !== lyricRef.current!.length - 1) {
const nextTime = lyricRef.current![index + 1][0];
const elapsed = performance.now() - start;
2024-08-23 08:19:27 -07:00
lyricTimer.current = setTimeout(
() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
},
nextTime - timeInMs - elapsed,
);
2023-07-01 19:10:05 -07:00
}
},
[],
);
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);
};
}
2023-05-22 17:38:31 -07:00
2023-07-01 19:10:05 -07:00
return () => {};
}, [getCurrentTime, lyrics, playbackType, setCurrentLyric, status]);
2023-05-22 17:38:31 -07:00
2023-07-01 19:10:05 -07:00
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;
2023-07-01 19:10:05 -07:00
if (!changed) {
return () => {};
}
2023-07-01 19:10:05 -07:00
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
2023-07-01 19:10:05 -07:00
let rejected = false;
2023-07-01 19:10:05 -07:00
delayMsRef.current = settings.delayMs;
2023-07-01 19:10:05 -07:00
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
2023-07-01 19:10:05 -07:00
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
2023-07-01 19:10:05 -07:00
return true;
})
.catch(console.error);
2023-05-22 17:38:31 -07:00
2023-07-01 19:10:05 -07:00
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]);
2023-07-01 19:10:05 -07:00
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);
}
2023-07-01 19:10:05 -07:00
return;
}
// If the time goes back to 0 and we are still playing, this suggests that
// we may be playing the same track (repeat one). In this case, we also
// need to restart playback
const restarted = status === PlayerStatus.PLAYING && now === 0;
if (!seeked && !restarted) {
2023-07-01 19:10:05 -07:00
return;
}
2023-07-01 19:10:05 -07:00
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
2023-07-01 19:10:05 -07:00
setCurrentLyric(now * 1000 - delayMsRef.current);
}, [now, seeked, setCurrentLyric, status]);
2023-07-01 19:10:05 -07:00
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);
}
2023-07-01 19:10:05 -07:00
timerEpoch.current += 1;
}, []);
2023-07-01 19:10:05 -07:00
const hideScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.add('hide-scrollbar');
};
2023-07-01 19:10:05 -07:00
const showScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.remove('hide-scrollbar');
};
2023-05-22 17:38:31 -07:00
2023-07-01 19:10:05 -07:00
return (
<div
className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')}
2023-07-01 19:10:05 -07:00
id="sychronized-lyrics-scroll-container"
onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar}
style={{ gap: `${settings.gap}px` }}
2023-07-01 19:10:05 -07:00
>
{settings.showProvider && source && (
2023-07-01 19:10:05 -07:00
<LyricLine
alignment={settings.alignment}
2023-07-01 19:10:05 -07:00
className="lyric-credit"
fontSize={settings.fontSize}
2023-07-01 19:10:05 -07:00
text={`Provided by ${source}`}
/>
)}
{settings.showMatch && remote && (
2023-07-01 19:10:05 -07:00
<LyricLine
alignment={settings.alignment}
2023-07-01 19:10:05 -07:00
className="lyric-credit"
fontSize={settings.fontSize}
2023-07-01 19:10:05 -07:00
text={`"${name} by ${artist}"`}
/>
)}
2024-07-03 08:24:31 +00:00
{lyrics.map(([time, text], idx) => (
<div key={idx}>
<LyricLine
alignment={settings.alignment}
className="lyric-line synchronized"
fontSize={settings.fontSize}
id={`lyric-${idx}`}
onClick={() => handleSeek(time / 1000)}
text={text}
/>
{translatedLyrics && (
<LyricLine
alignment={settings.alignment}
className="lyric-line synchronized translation"
fontSize={settings.fontSize * 0.8}
onClick={() => handleSeek(time / 1000)}
text={translatedLyrics.split('\n')[idx]}
/>
)}
</div>
2023-07-01 19:10:05 -07:00
))}
</div>
2023-07-01 19:10:05 -07:00
);
2023-05-22 17:38:31 -07:00
};