{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-transcript-viewer", "devDependencies": [ "@elevenlabs/elevenlabs-js" ], "files": [ { "path": "hooks/use-transcript-viewer.ts", "content": "\"use client\"\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type RefObject,\n} from \"react\"\nimport type { CharacterAlignmentResponseModel } from \"@elevenlabs/elevenlabs-js/api/types/CharacterAlignmentResponseModel\"\n\ntype ComposeSegmentsOptions = {\n hideAudioTags?: boolean\n}\n\ntype BaseSegment = {\n segmentIndex: number\n text: string\n}\n\ntype TranscriptWord = BaseSegment & {\n kind: \"word\"\n wordIndex: number\n startTime: number\n endTime: number\n}\n\ntype GapSegment = BaseSegment & {\n kind: \"gap\"\n}\n\ntype TranscriptSegment = TranscriptWord | GapSegment\n\ntype ComposeSegmentsResult = {\n segments: TranscriptSegment[]\n words: TranscriptWord[]\n}\n\ntype SegmentComposer = (\n alignment: CharacterAlignmentResponseModel\n) => ComposeSegmentsResult\n\nfunction composeSegments(\n alignment: CharacterAlignmentResponseModel,\n options: ComposeSegmentsOptions = {}\n): ComposeSegmentsResult {\n const {\n characters,\n characterStartTimesSeconds: starts,\n characterEndTimesSeconds: ends,\n } = alignment\n\n const segments: TranscriptSegment[] = []\n const words: TranscriptWord[] = []\n\n let wordBuffer = \"\"\n let whitespaceBuffer = \"\"\n let wordStart = 0\n let wordEnd = 0\n let segmentIndex = 0\n let wordIndex = 0\n let insideAudioTag = false\n\n const hideAudioTags = options.hideAudioTags ?? false\n\n const flushWhitespace = () => {\n if (!whitespaceBuffer) return\n segments.push({\n kind: \"gap\",\n segmentIndex: segmentIndex++,\n text: whitespaceBuffer,\n })\n whitespaceBuffer = \"\"\n }\n\n const flushWord = () => {\n if (!wordBuffer) return\n const word: TranscriptWord = {\n kind: \"word\",\n segmentIndex: segmentIndex++,\n wordIndex: wordIndex++,\n text: wordBuffer,\n startTime: wordStart,\n endTime: wordEnd,\n }\n segments.push(word)\n words.push(word)\n wordBuffer = \"\"\n }\n\n for (let i = 0; i < characters.length; i++) {\n const char = characters[i]\n const start = starts[i] ?? 0\n const end = ends[i] ?? start\n\n if (hideAudioTags) {\n if (char === \"[\") {\n flushWord()\n whitespaceBuffer = \"\"\n insideAudioTag = true\n continue\n }\n\n if (insideAudioTag) {\n if (char === \"]\") insideAudioTag = false\n continue\n }\n }\n\n if (/\\s/.test(char)) {\n flushWord()\n whitespaceBuffer += char\n continue\n }\n\n if (whitespaceBuffer) {\n flushWhitespace()\n }\n\n if (!wordBuffer) {\n wordBuffer = char\n wordStart = start\n wordEnd = end\n } else {\n wordBuffer += char\n wordEnd = end\n }\n }\n\n flushWord()\n flushWhitespace()\n\n return { segments, words }\n}\n\ntype UseTranscriptViewerProps = {\n alignment: CharacterAlignmentResponseModel\n segmentComposer?: SegmentComposer\n hideAudioTags?: boolean\n onPlay?: () => void\n onPause?: () => void\n onTimeUpdate?: (time: number) => void\n onEnded?: () => void\n onDurationChange?: (duration: number) => void\n}\n\ntype UseTranscriptViewerResult = {\n segments: TranscriptSegment[]\n words: TranscriptWord[]\n spokenSegments: TranscriptSegment[]\n unspokenSegments: TranscriptSegment[]\n currentWord: TranscriptWord | null\n currentSegmentIndex: number\n currentWordIndex: number\n seekToTime: (time: number) => void\n seekToWord: (word: number | TranscriptWord) => void\n audioRef: RefObject\n isPlaying: boolean\n isScrubbing: boolean\n duration: number\n currentTime: number\n play: () => void\n pause: () => void\n startScrubbing: () => void\n endScrubbing: () => void\n}\n\nfunction useTranscriptViewer({\n alignment,\n hideAudioTags = true,\n segmentComposer,\n onPlay,\n onPause,\n onTimeUpdate,\n onEnded,\n onDurationChange,\n}: UseTranscriptViewerProps): UseTranscriptViewerResult {\n const audioRef = useRef(null)\n const rafRef = useRef(null)\n const handleTimeUpdateRef = useRef<(time: number) => void>(() => {})\n const onDurationChangeRef = useRef<(duration: number) => void>(() => {})\n\n const [isPlaying, setIsPlaying] = useState(false)\n const [isScrubbing, setIsScrubbing] = useState(false)\n const [duration, setDuration] = useState(0)\n const [currentTime, setCurrentTime] = useState(0)\n\n const { segments, words } = useMemo(() => {\n if (segmentComposer) {\n return segmentComposer(alignment)\n }\n return composeSegments(alignment, { hideAudioTags })\n }, [segmentComposer, alignment, hideAudioTags])\n\n // Best-effort duration guess from alignment data while metadata loads\n const guessedDuration = useMemo(() => {\n const ends = alignment?.characterEndTimesSeconds\n if (Array.isArray(ends) && ends.length) {\n const last = ends[ends.length - 1]\n return Number.isFinite(last) ? last : 0\n }\n if (words.length) {\n const lastWord = words[words.length - 1]\n return Number.isFinite(lastWord.endTime) ? lastWord.endTime : 0\n }\n return 0\n }, [alignment, words])\n\n const [currentWordIndex, setCurrentWordIndex] = useState(() =>\n words.length ? 0 : -1\n )\n\n useEffect(() => {\n setCurrentTime(0)\n setDuration(guessedDuration)\n setIsPlaying(false)\n setCurrentWordIndex(words.length ? 0 : -1)\n }, [words.length, alignment, guessedDuration])\n\n const findWordIndex = useCallback(\n (time: number) => {\n if (!words.length) return -1\n let lo = 0\n let hi = words.length - 1\n let answer = -1\n while (lo <= hi) {\n const mid = Math.floor((lo + hi) / 2)\n const word = words[mid]\n if (time >= word.startTime && time < word.endTime) {\n answer = mid\n break\n }\n if (time < word.startTime) {\n hi = mid - 1\n } else {\n lo = mid + 1\n }\n }\n return answer\n },\n [words]\n )\n\n const handleTimeUpdate = useCallback(\n (currentTime: number) => {\n if (!words.length) return\n\n const currentWord =\n currentWordIndex >= 0 && currentWordIndex < words.length\n ? words[currentWordIndex]\n : undefined\n\n if (!currentWord) {\n const found = findWordIndex(currentTime)\n if (found !== -1) setCurrentWordIndex(found)\n return\n }\n\n let next = currentWordIndex\n if (\n currentTime >= currentWord.endTime &&\n currentWordIndex + 1 < words.length\n ) {\n while (\n next + 1 < words.length &&\n currentTime >= words[next + 1].startTime\n ) {\n next++\n }\n // If we're inside the next word's window, pick it.\n if (currentTime < words[next].endTime) {\n setCurrentWordIndex(next)\n return\n }\n // If we landed in a timing gap (no word contains currentTime),\n // snap to the latest word that started at or before currentTime.\n setCurrentWordIndex(next)\n return\n }\n\n if (currentTime < currentWord.startTime) {\n const found = findWordIndex(currentTime)\n if (found !== -1) setCurrentWordIndex(found)\n return\n }\n\n const found = findWordIndex(currentTime)\n if (found !== -1 && found !== currentWordIndex) {\n setCurrentWordIndex(found)\n }\n },\n [findWordIndex, currentWordIndex, words]\n )\n\n useEffect(() => {\n handleTimeUpdateRef.current = handleTimeUpdate\n }, [handleTimeUpdate])\n\n useEffect(() => {\n onDurationChangeRef.current = onDurationChange ?? (() => {})\n }, [onDurationChange])\n\n const stopRaf = useCallback(() => {\n if (rafRef.current != null) {\n cancelAnimationFrame(rafRef.current)\n rafRef.current = null\n }\n }, [])\n\n const startRaf = useCallback(() => {\n if (rafRef.current != null) return\n const tick = () => {\n const node = audioRef.current\n if (!node) {\n rafRef.current = null\n return\n }\n const time = node.currentTime\n setCurrentTime(time)\n handleTimeUpdateRef.current(time)\n // Opportunistically pick up duration when metadata arrives, even if\n // duration events were missed or coalesced by the browser.\n if (Number.isFinite(node.duration) && node.duration > 0) {\n setDuration((prev) => {\n if (!prev) {\n onDurationChangeRef.current(node.duration)\n return node.duration\n }\n return prev\n })\n }\n rafRef.current = requestAnimationFrame(tick)\n }\n rafRef.current = requestAnimationFrame(tick)\n }, [audioRef])\n\n useEffect(() => {\n const audio = audioRef.current\n if (!audio) return\n\n const syncPlayback = () => setIsPlaying(!audio.paused)\n const syncTime = () => setCurrentTime(audio.currentTime)\n const syncDuration = () =>\n setDuration(Number.isFinite(audio.duration) ? audio.duration : 0)\n\n const handlePlay = () => {\n syncPlayback()\n startRaf()\n onPlay?.()\n }\n const handlePause = () => {\n syncPlayback()\n syncTime()\n stopRaf()\n onPause?.()\n }\n const handleEnded = () => {\n syncPlayback()\n syncTime()\n stopRaf()\n onEnded?.()\n }\n const handleTimeUpdate = () => {\n syncTime()\n onTimeUpdate?.(audio.currentTime)\n }\n const handleSeeked = () => {\n syncTime()\n handleTimeUpdateRef.current(audio.currentTime)\n }\n const handleDuration = () => {\n syncDuration()\n onDurationChange?.(audio.duration)\n }\n\n syncPlayback()\n syncTime()\n syncDuration()\n if (!audio.paused) {\n startRaf()\n } else {\n stopRaf()\n }\n\n audio.addEventListener(\"play\", handlePlay)\n audio.addEventListener(\"pause\", handlePause)\n audio.addEventListener(\"ended\", handleEnded)\n audio.addEventListener(\"timeupdate\", handleTimeUpdate)\n audio.addEventListener(\"seeked\", handleSeeked)\n audio.addEventListener(\"durationchange\", handleDuration)\n audio.addEventListener(\"loadedmetadata\", handleDuration)\n\n return () => {\n stopRaf()\n audio.removeEventListener(\"play\", handlePlay)\n audio.removeEventListener(\"pause\", handlePause)\n audio.removeEventListener(\"ended\", handleEnded)\n audio.removeEventListener(\"timeupdate\", handleTimeUpdate)\n audio.removeEventListener(\"seeked\", handleSeeked)\n audio.removeEventListener(\"durationchange\", handleDuration)\n audio.removeEventListener(\"loadedmetadata\", handleDuration)\n }\n }, [\n audioRef,\n startRaf,\n stopRaf,\n onPlay,\n onPause,\n onEnded,\n onTimeUpdate,\n onDurationChange,\n ])\n\n const seekToTime = useCallback(\n (time: number) => {\n const node = audioRef.current\n if (!node) return\n // Optimistically update UI time immediately to reflect the seek,\n // since some browsers coalesce timeupdate/seeked events under rapid seeks.\n setCurrentTime(time)\n node.currentTime = time\n handleTimeUpdateRef.current(time)\n },\n [audioRef]\n )\n\n const seekToWord = useCallback(\n (word: number | TranscriptWord) => {\n const target = typeof word === \"number\" ? words[word] : word\n if (!target) return\n seekToTime(target.startTime)\n },\n [seekToTime, words]\n )\n\n const play = useCallback(() => {\n const audio = audioRef.current\n if (!audio) return\n if (audio.paused) {\n void audio.play()\n }\n }, [audioRef])\n\n const pause = useCallback(() => {\n const audio = audioRef.current\n if (audio && !audio.paused) {\n audio.pause()\n }\n }, [audioRef])\n\n const startScrubbing = useCallback(() => {\n setIsScrubbing(true)\n stopRaf()\n }, [stopRaf])\n\n const endScrubbing = useCallback(() => {\n setIsScrubbing(false)\n const node = audioRef.current\n if (node && !node.paused) {\n startRaf()\n }\n }, [audioRef, startRaf])\n\n const currentWord =\n currentWordIndex >= 0 && currentWordIndex < words.length\n ? words[currentWordIndex]\n : null\n const currentSegmentIndex = currentWord?.segmentIndex ?? -1\n\n const spokenSegments = useMemo(() => {\n if (!segments.length || currentSegmentIndex <= 0) return []\n return segments.slice(0, currentSegmentIndex)\n }, [segments, currentSegmentIndex])\n\n const unspokenSegments = useMemo(() => {\n if (!segments.length) return []\n if (currentSegmentIndex === -1) return segments\n if (currentSegmentIndex + 1 >= segments.length) return []\n return segments.slice(currentSegmentIndex + 1)\n }, [segments, currentSegmentIndex])\n\n return {\n segments,\n words,\n spokenSegments,\n unspokenSegments,\n currentWord,\n currentSegmentIndex,\n currentWordIndex,\n seekToTime,\n seekToWord,\n audioRef,\n isPlaying,\n isScrubbing,\n duration,\n currentTime,\n play,\n pause,\n startScrubbing,\n endScrubbing,\n }\n}\n\nexport { useTranscriptViewer }\nexport type {\n UseTranscriptViewerProps,\n UseTranscriptViewerResult,\n ComposeSegmentsOptions,\n ComposeSegmentsResult,\n SegmentComposer,\n TranscriptSegment,\n TranscriptWord,\n CharacterAlignmentResponseModel,\n}\n", "type": "registry:hook" } ], "type": "registry:hook" }