"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AudioLinesIcon, GaugeIcon, PauseIcon, PlayIcon, RotateCcwIcon, SlidersHorizontalIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { parseEmotionTags } from "@/lib/voiceover/emotionTags"; import { DEFAULT_WORDS_PER_MINUTE, MAX_WORDS_PER_MINUTE, MIN_WORDS_PER_MINUTE, buildPacingTimeline, } from "@/lib/voiceover/pacing"; const MIN_SCROLL_SPEED = 5; const MAX_SCROLL_SPEED = 100; const DEFAULT_SCROLL_SPEED = 20; const TAG_COLORS = [ { text: "text-sky-200/75", underline: "decoration-sky-300/50", label: "text-sky-300/80" }, { text: "text-amber-200/75", underline: "decoration-amber-300/50", label: "text-amber-300/80" }, { text: "text-emerald-200/75", underline: "decoration-emerald-300/50", label: "text-emerald-300/80", }, { text: "text-rose-200/75", underline: "decoration-rose-300/50", label: "text-rose-300/80" }, { text: "text-violet-200/75", underline: "decoration-violet-300/50", label: "text-violet-300/80", }, { text: "text-teal-200/75", underline: "decoration-teal-300/50", label: "text-teal-300/80" }, { text: "text-fuchsia-200/75", underline: "decoration-fuchsia-300/50", label: "text-fuchsia-300/80", }, { text: "text-orange-200/75", underline: "decoration-orange-300/50", label: "text-orange-300/80", }, ] as const; function tagColor(tags: readonly string[]) { const key = tags.join(", "); let hash = 0; for (let index = 0; index < key.length; index += 1) { hash = (hash * 31 + key.charCodeAt(index)) >>> 0; } return TAG_COLORS[hash % TAG_COLORS.length]; } export function TeleprompterReader({ script }: { readonly script: string }) { const readerRef = useRef(null); const elapsedRef = useRef(0); const [isScrolling, setIsScrolling] = useState(false); const [scrollSpeed, setScrollSpeed] = useState(DEFAULT_SCROLL_SPEED); const [pacingEnabled, setPacingEnabled] = useState(false); const [wordsPerMinute, setWordsPerMinute] = useState(DEFAULT_WORDS_PER_MINUTE); const [elapsedMs, setElapsedMs] = useState(0); const segments = useMemo(() => parseEmotionTags(script), [script]); const timeline = useMemo( () => buildPacingTimeline(segments, wordsPerMinute), [segments, wordsPerMinute], ); const toggleScrolling = useCallback(() => { setIsScrolling((scrolling) => !scrolling); }, []); const scrollMultiplier = useCallback( (elapsed: number) => { let multiplier = 1; for (const directive of timeline.scrollDirectives) { if (elapsed < directive.atMs) break; multiplier = directive.multiplier; } return multiplier; }, [timeline.scrollDirectives], ); useEffect(() => { if (!isScrolling) return; let animationFrame = 0; let previousTime = performance.now(); const scroll = (now: number) => { const reader = readerRef.current; if (!reader) { setIsScrolling(false); return; } const delta = now - previousTime; previousTime = now; if (pacingEnabled) { elapsedRef.current += delta; setElapsedMs(elapsedRef.current); } const effectiveSpeed = scrollSpeed * (pacingEnabled ? scrollMultiplier(elapsedRef.current) : 1); const maxScrollTop = reader.scrollHeight - reader.clientHeight; const scrollFinished = maxScrollTop <= 0 || reader.scrollTop >= maxScrollTop; const pacingFinished = !pacingEnabled || elapsedRef.current >= timeline.totalMs; if (scrollFinished && pacingFinished) { setIsScrolling(false); return; } if (!scrollFinished) { reader.scrollTop = Math.min( maxScrollTop, reader.scrollTop + (effectiveSpeed * delta) / 1000, ); } animationFrame = requestAnimationFrame(scroll); }; animationFrame = requestAnimationFrame(scroll); return () => cancelAnimationFrame(animationFrame); }, [isScrolling, scrollSpeed, pacingEnabled, timeline.totalMs, scrollMultiplier]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code !== "Space" || event.repeat) return; const target = event.target; if ( target instanceof HTMLElement && (target.isContentEditable || ["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(target.tagName)) ) { return; } event.preventDefault(); toggleScrolling(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [toggleScrolling]); function resetReader() { const reader = readerRef.current; if (reader) reader.scrollTop = 0; elapsedRef.current = 0; setElapsedMs(0); setIsScrolling(false); } function handlePacingToggle(enabled: boolean) { elapsedRef.current = 0; setElapsedMs(0); setPacingEnabled(enabled); } function handleWordsPerMinuteChange(value: string) { const next = Number(value); if (!Number.isFinite(next)) return; setWordsPerMinute(Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, next))); } function renderSpokenText( segment: (typeof segments)[number], pacing: (typeof timeline.segmentPacing)[number], wordClass?: string, ) { if (!pacingEnabled || !pacing || pacing.words.length === 0) { return segment.text; } return ( <> {pacing.leadingWhitespace} {pacing.words.map((word, wordIndex) => ( = word.endMs && "opacity-35", )} > {word.text} {word.whitespaceAfter} ))} ); } function handleSpeedChange(value: string) { const nextSpeed = Number(value); if (!Number.isFinite(nextSpeed)) return; setScrollSpeed(Math.min(MAX_SCROLL_SPEED, Math.max(MIN_SCROLL_SPEED, nextSpeed))); } return (
Teleprompter Press spacebar to start or pause the reader.
{pacingEnabled && (
handleWordsPerMinuteChange(event.target.value)} className="h-7 w-20 text-xs" aria-label="Speaking pace in words per minute" /> wpm
)}
handleSpeedChange(event.target.value)} className="w-24" />

{segments.map((segment, index) => { if (segment.directive) { return ( {segment.directive} ); } if (segment.sfx) { return ( {segment.sfx} ); } if (segment.tags.length === 0) { return ( {renderSpokenText(segment, timeline.segmentPacing[index])} ); } const color = tagColor(segment.tags); const segmentPacing = timeline.segmentPacing[index]; const pacingActive = pacingEnabled && segmentPacing !== undefined; return ( {segment.tags.join(", ")} {renderSpokenText( segment, segmentPacing, pacingActive ? cn("underline", color.underline) : undefined, )} ); })}

Constant scroll speed ยท {scrollSpeed} px/s
); }