mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-12 13:39:58 +02:00
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import clsx from 'clsx';
|
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
|
|
import styles from './synchronized-karaoke-lyrics.module.css';
|
|
|
|
import {
|
|
findOverlayLineByTime,
|
|
getLyricLineStartMs,
|
|
getLyricLineText,
|
|
normalizeLyrics,
|
|
} from '/@/renderer/features/lyrics/api/lyrics-utils';
|
|
import { SyncedRomajiLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
|
|
import { useLyricsAnimationEngine } from '/@/renderer/features/lyrics/hooks/use-lyrics-animation-engine';
|
|
import {
|
|
LYRICS_SCROLL_CONTAINER_ID,
|
|
useSynchronizedLyricsBase,
|
|
} from '/@/renderer/features/lyrics/hooks/use-synchronized-lyrics-base';
|
|
import { KaraokeLyricLine } from '/@/renderer/features/lyrics/karaoke-lyric-line';
|
|
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
|
import { subscribePlayerStatus, usePlayerStoreBase } from '/@/renderer/store';
|
|
import { subscribePlayerProgress, useTimestampStoreBase } from '/@/renderer/store/timestamp.store';
|
|
import {
|
|
FullLyricsMetadata,
|
|
LyricAgent,
|
|
SynchronizedLyrics as SynchronizedLyricsData,
|
|
} from '/@/shared/types/domain-types';
|
|
import { PlayerStatus } from '/@/shared/types/types';
|
|
|
|
export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
|
agents?: LyricAgent[];
|
|
lyrics: SynchronizedLyricsData;
|
|
offsetMs?: number;
|
|
pronunciationLyrics?: null | SynchronizedLyricsData;
|
|
romajiLyrics?: null | SynchronizedLyricsData;
|
|
settingsKey?: string;
|
|
style?: React.CSSProperties;
|
|
syncedRomajiLyrics?: null | SyncedRomajiLyrics;
|
|
translatedLyrics?: null | string;
|
|
translationLyrics?: null | SynchronizedLyricsData;
|
|
}
|
|
|
|
const SEEK_DETECT_THRESHOLD_MS = 500;
|
|
|
|
export const SynchronizedKaraokeLyrics = ({
|
|
agents,
|
|
artist,
|
|
lyrics,
|
|
name,
|
|
offsetMs,
|
|
pronunciationLyrics,
|
|
remote,
|
|
romajiLyrics,
|
|
settingsKey = 'default',
|
|
source,
|
|
style,
|
|
syncedRomajiLyrics,
|
|
translatedLyrics,
|
|
translationLyrics,
|
|
}: SynchronizedKaraokeLyricsProps) => {
|
|
const {
|
|
containerRef,
|
|
containerStyle,
|
|
delayMsRef,
|
|
followRef,
|
|
handleSeek,
|
|
hideScrollbar,
|
|
lineLeadTimeMsRef,
|
|
lyricRef,
|
|
resumeAutoscroll,
|
|
scrollAnimStateRef,
|
|
settings,
|
|
showScrollbar,
|
|
} = useSynchronizedLyricsBase(settingsKey, offsetMs);
|
|
|
|
const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]);
|
|
const rafRef = useRef<null | number>(null);
|
|
const statusRef = useRef(usePlayerStoreBase.getState().player.status);
|
|
const lastSyncedTimeRef = useRef(0);
|
|
const playbackAnchorRef = useRef({
|
|
// eslint-disable-next-line react-hooks/purity
|
|
eventCreationTime: Date.now(),
|
|
timeMs: useTimestampStoreBase.getState().timestamp * 1000,
|
|
});
|
|
|
|
const {
|
|
rebuildLyricsData,
|
|
reset,
|
|
resumeAutoscroll: resumeEngineAutoscroll,
|
|
tick,
|
|
} = useLyricsAnimationEngine({
|
|
animStateRef: scrollAnimStateRef,
|
|
containerRef,
|
|
followRef,
|
|
lineIdPrefix: 'karaoke-line',
|
|
lineLeadTimeMsRef,
|
|
lyrics: normalizedLyrics,
|
|
scrollContainerId: LYRICS_SCROLL_CONTAINER_ID,
|
|
});
|
|
|
|
const handleContainerClick = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
const wordTarget = (event.target as HTMLElement).closest('[data-word-start]');
|
|
if (wordTarget) {
|
|
const wordStart = Number((wordTarget as HTMLElement).dataset.wordStart);
|
|
if (Number.isFinite(wordStart)) {
|
|
resumeAutoscroll();
|
|
resumeEngineAutoscroll();
|
|
handleSeek(wordStart / 1000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const target = (event.target as HTMLElement).closest('[data-lyric-time]');
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const time = Number((target as HTMLElement).dataset.lyricTime);
|
|
if (time >= 0 && Number.isFinite(time)) {
|
|
resumeAutoscroll();
|
|
resumeEngineAutoscroll();
|
|
handleSeek(time / 1000);
|
|
}
|
|
},
|
|
[handleSeek, resumeAutoscroll, resumeEngineAutoscroll],
|
|
);
|
|
|
|
const syncAtTime = useCallback(
|
|
(
|
|
timeInMs: number,
|
|
isPlaying: boolean,
|
|
options?: { eventCreationTime?: number; forceReset?: boolean; forceResync?: boolean },
|
|
) => {
|
|
if (options?.forceReset) {
|
|
reset();
|
|
rebuildLyricsData();
|
|
}
|
|
|
|
tick(timeInMs, isPlaying, {
|
|
eventCreationTime: options?.eventCreationTime ?? Date.now(),
|
|
forceResync: options?.forceResync ?? false,
|
|
});
|
|
lastSyncedTimeRef.current = timeInMs;
|
|
},
|
|
[rebuildLyricsData, reset, tick],
|
|
);
|
|
|
|
const updatePlaybackAnchor = useCallback((timestampSec: number) => {
|
|
playbackAnchorRef.current = {
|
|
eventCreationTime: Date.now(),
|
|
timeMs: timestampSec * 1000,
|
|
};
|
|
}, []);
|
|
|
|
const stopRaf = useCallback(() => {
|
|
if (rafRef.current !== null) {
|
|
cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const startRaf = useCallback(() => {
|
|
stopRaf();
|
|
|
|
const runTick = () => {
|
|
if (statusRef.current !== PlayerStatus.PLAYING) {
|
|
stopRaf();
|
|
return;
|
|
}
|
|
|
|
const anchor = playbackAnchorRef.current;
|
|
const timeInMs = anchor.timeMs + delayMsRef.current;
|
|
|
|
syncAtTime(timeInMs, true, {
|
|
eventCreationTime: anchor.eventCreationTime,
|
|
});
|
|
|
|
rafRef.current = requestAnimationFrame(runTick);
|
|
};
|
|
|
|
rafRef.current = requestAnimationFrame(runTick);
|
|
}, [delayMsRef, stopRaf, syncAtTime]);
|
|
|
|
const syncFromCurrentTimestamp = useCallback(() => {
|
|
const timestamp = useTimestampStoreBase.getState().timestamp;
|
|
updatePlaybackAnchor(timestamp);
|
|
const isPlaying = statusRef.current === PlayerStatus.PLAYING;
|
|
const timeInMs = timestamp * 1000 + delayMsRef.current;
|
|
syncAtTime(timeInMs, isPlaying, { forceReset: true, forceResync: true });
|
|
}, [delayMsRef, syncAtTime, updatePlaybackAnchor]);
|
|
|
|
useEffect(() => {
|
|
lyricRef.current = normalizedLyrics;
|
|
lastSyncedTimeRef.current = 0;
|
|
|
|
const frame = requestAnimationFrame(() => {
|
|
rebuildLyricsData();
|
|
|
|
if (statusRef.current === PlayerStatus.PLAYING) {
|
|
startRaf();
|
|
} else {
|
|
syncFromCurrentTimestamp();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
stopRaf();
|
|
reset();
|
|
};
|
|
}, [
|
|
lyricRef,
|
|
normalizedLyrics,
|
|
rebuildLyricsData,
|
|
reset,
|
|
startRaf,
|
|
stopRaf,
|
|
syncFromCurrentTimestamp,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
syncFromCurrentTimestamp();
|
|
}, [offsetMs, syncFromCurrentTimestamp]);
|
|
|
|
useEffect(() => {
|
|
statusRef.current = usePlayerStoreBase.getState().player.status;
|
|
|
|
const unsubscribe = subscribePlayerStatus(({ status }) => {
|
|
statusRef.current = status;
|
|
|
|
if (status !== PlayerStatus.PLAYING) {
|
|
stopRaf();
|
|
syncFromCurrentTimestamp();
|
|
return;
|
|
}
|
|
|
|
startRaf();
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [startRaf, stopRaf, syncFromCurrentTimestamp]);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = subscribePlayerProgress(({ timestamp }) => {
|
|
const isPlaying = statusRef.current === PlayerStatus.PLAYING;
|
|
const timeInMs = timestamp * 1000 + delayMsRef.current;
|
|
const previousTimeMs = lastSyncedTimeRef.current;
|
|
const isSeek =
|
|
previousTimeMs > 0 &&
|
|
Math.abs(timeInMs - previousTimeMs) > SEEK_DETECT_THRESHOLD_MS;
|
|
|
|
updatePlaybackAnchor(timestamp);
|
|
|
|
if (!isPlaying) {
|
|
syncAtTime(timeInMs, false, { forceReset: true, forceResync: true });
|
|
return;
|
|
}
|
|
|
|
if (isSeek) {
|
|
syncAtTime(timeInMs, true, {
|
|
eventCreationTime: playbackAnchorRef.current.eventCreationTime,
|
|
forceReset: true,
|
|
forceResync: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [delayMsRef, syncAtTime, updatePlaybackAnchor]);
|
|
|
|
const getOverlayText = (
|
|
overlayLyrics: null | SynchronizedLyricsData | undefined,
|
|
startMs: number,
|
|
fallback?: null | string,
|
|
) => {
|
|
if (overlayLyrics) {
|
|
return findOverlayLineByTime(overlayLyrics, startMs);
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(styles.container, 'synchronized-karaoke-lyrics overlay-scrollbar')}
|
|
id={LYRICS_SCROLL_CONTAINER_ID}
|
|
onClick={handleContainerClick}
|
|
onMouseEnter={showScrollbar}
|
|
onMouseLeave={hideScrollbar}
|
|
ref={containerRef}
|
|
style={{ ...containerStyle, ...style }}
|
|
>
|
|
{settings.showProvider && source && (
|
|
<LyricLine
|
|
alignment={settings.alignment}
|
|
className="lyric-credit"
|
|
fontSize={settings.fontSize}
|
|
text={`Provided by ${source}`}
|
|
/>
|
|
)}
|
|
{settings.showMatch && remote && (
|
|
<LyricLine
|
|
alignment={settings.alignment}
|
|
className="lyric-credit"
|
|
fontSize={settings.fontSize}
|
|
text={`"${name} by ${artist}"`}
|
|
/>
|
|
)}
|
|
{normalizedLyrics.map((rawLine, idx) => {
|
|
const lineStartMs = getLyricLineStartMs(rawLine);
|
|
const pronunciationText = getOverlayText(
|
|
pronunciationLyrics,
|
|
lineStartMs,
|
|
romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined,
|
|
);
|
|
const translationText = getOverlayText(
|
|
translationLyrics,
|
|
lineStartMs,
|
|
translatedLyrics?.split('\n')[idx],
|
|
);
|
|
|
|
if (!rawLine.cueLines?.length) {
|
|
return (
|
|
<LyricLine
|
|
alignment={settings.alignment}
|
|
className="lyric-line synchronized"
|
|
data-lyric-time={lineStartMs}
|
|
fontSize={settings.fontSize}
|
|
id={`karaoke-line-${idx}`}
|
|
key={idx}
|
|
romajiText={pronunciationText}
|
|
text={getLyricLineText(rawLine)}
|
|
translatedText={translationText}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<KaraokeLyricLine
|
|
agents={agents}
|
|
alignment={settings.alignment}
|
|
className="synchronized"
|
|
cueLines={rawLine.cueLines}
|
|
data-lyric-time={lineStartMs}
|
|
fontSize={settings.fontSize}
|
|
id={`karaoke-line-${idx}`}
|
|
key={idx}
|
|
lineIndex={idx}
|
|
romajiCueLines={syncedRomajiLyrics?.[idx] ?? null}
|
|
romajiText={pronunciationText}
|
|
translatedText={translationText}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|