Files
suaveplan/packages/ui/react/react-audio-player/src/karaoke/use-lyrics/use-lyrics.ts
2026-05-27 23:47:53 +01:00

59 lines
2.1 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { activeLyricIndex, parseLrc } from "../lrc-parser/lrc-parser.js";
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
import type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";
const DEFAULT_INTERVAL_MS = 100;
/**
* Engine-agnostic karaoke hook. Accepts a pre-parsed `lyrics` array
* (preferred) or a raw `lrc` string, plus a `currentSec` value, and
* returns the active line. Sampling cadence is decoupled from the
* caller's render rate: a 100 ms interval (configurable) forces a
* re-render even when neither `lyrics` nor `currentSec` mutate.
*
* The hook is deliberately decoupled from `useTransportTime` so it
* can be driven from any clock — `<LyricsDisplay>` wires the
* transport in for you when used inside `<AudioProvider>`, but a
* preview panel running under a deterministic test clock can call
* this hook directly with a hand-fed `currentSec`.
*/
export function useLyrics(opts: UseLyricsOptions): UseLyricsResult {
const lyrics: readonly Lyric[] = useMemo(() => {
if (opts.lyrics) return opts.lyrics;
if (typeof opts.lrc === "string") return parseLrc(opts.lrc);
return [];
}, [opts.lyrics, opts.lrc]);
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
// A heartbeat counter forces a re-render every `intervalMs` so
// the memo below recomputes even when `currentSec` did not
// change between commits — relevant when the consumer's clock
// mutates a ref or external store the hook cannot subscribe to.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(
() => {
setTick((t) => (t + 1) % 1_000_000);
},
Math.max(1, intervalMs)
);
return () => {
clearInterval(id);
};
}, [intervalMs]);
const activeIndex = useMemo(
() => activeLyricIndex(lyrics, opts.currentSec),
[lyrics, opts.currentSec]
);
return {
lyrics,
activeIndex,
activeLyric: activeIndex >= 0 ? lyrics[activeIndex] : undefined,
};
}