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 { 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); 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) => { 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 (
{settings.showProvider && source && ( )} {settings.showMatch && remote && ( )} {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 ( ); } return ( ); })}
); };