1
0
Fork 0
polaris-task-force/src/components/frontend/voiceover/TeleprompterReader.tsx
Z8MB1E 44b78ba7d9 feat(voiceover): add submission pages, recorder UI, and server actions
Add /voiceover script list and take detail pages, teleprompter reader, voice capture/recorder components, submission server actions with a 9 MB audio cap, and a Contribute > Voiceover entry in the user menu.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00

373 lines
12 KiB
TypeScript

"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<HTMLDivElement | null>(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) => (
<span
key={`word-${wordIndex}`}
className={cn(
"transition-opacity duration-500",
wordClass,
elapsedMs >= word.endMs && "opacity-35",
)}
>
{word.text}
{word.whitespaceAfter}
</span>
))}
</>
);
}
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 (
<Card>
<CardHeader className="gap-3 border-b border-border">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<CardTitle>Teleprompter</CardTitle>
<CardDescription>Press spacebar to start or pause the reader.</CardDescription>
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Switch
id="voiceover-pacing"
checked={pacingEnabled}
onCheckedChange={handlePacingToggle}
aria-label="Toggle pacing highlighter"
/>
<Label
htmlFor="voiceover-pacing"
className="cursor-pointer text-xs text-muted-foreground"
>
Follow along
</Label>
</div>
{pacingEnabled && (
<div className="flex items-center gap-1.5">
<Input
id="voiceover-wpm"
type="number"
min={MIN_WORDS_PER_MINUTE}
max={MAX_WORDS_PER_MINUTE}
step={10}
value={wordsPerMinute}
onChange={(event) => handleWordsPerMinuteChange(event.target.value)}
className="h-7 w-20 text-xs"
aria-label="Speaking pace in words per minute"
/>
<span className="text-xs text-muted-foreground">wpm</span>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="voiceover-scroll-speed" className="text-xs text-muted-foreground">
Speed (px/s)
</Label>
<Input
id="voiceover-scroll-speed"
type="number"
min={MIN_SCROLL_SPEED}
max={MAX_SCROLL_SPEED}
step={5}
value={scrollSpeed}
onChange={(event) => handleSpeedChange(event.target.value)}
className="w-24"
/>
</div>
<Button type="button" onClick={toggleScrolling} aria-pressed={isScrolling}>
{isScrolling ? (
<PauseIcon data-icon="inline-start" />
) : (
<PlayIcon data-icon="inline-start" />
)}
{isScrolling ? "Pause" : "Start"}
</Button>
<Button type="button" variant="outline" size="icon" onClick={resetReader}>
<RotateCcwIcon />
<span className="sr-only">Reset teleprompter</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-4 sm:p-6">
<div
ref={readerRef}
tabIndex={0}
aria-label="Voiceover script reader"
className="max-h-[34rem] min-h-[24rem] overflow-y-auto scroll-auto rounded-md border border-border bg-background/70 px-4 py-8 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-8 sm:py-12"
>
<p className="whitespace-pre-wrap text-xl leading-relaxed tracking-wide text-foreground sm:text-2xl">
{segments.map((segment, index) => {
if (segment.directive) {
return (
<span
key={`directive-${index}`}
className="my-3 flex items-center gap-1.5 text-[10px] uppercase tracking-widest text-muted-foreground/70"
>
<SlidersHorizontalIcon className="size-3 shrink-0" aria-hidden />
{segment.directive}
</span>
);
}
if (segment.sfx) {
return (
<span
key={`sfx-${index}`}
className="my-6 flex items-center gap-1.5 text-xs italic text-muted-foreground"
>
<AudioLinesIcon className="size-3.5 shrink-0" aria-hidden />
{segment.sfx}
</span>
);
}
if (segment.tags.length === 0) {
return (
<span key={`text-${index}`}>
{renderSpokenText(segment, timeline.segmentPacing[index])}
</span>
);
}
const color = tagColor(segment.tags);
const segmentPacing = timeline.segmentPacing[index];
const pacingActive = pacingEnabled && segmentPacing !== undefined;
return (
<span key={`tagged-${index}`} className="relative inline">
<span
className={cn(
"absolute -top-3.5 left-0 whitespace-nowrap text-[10px] italic leading-none",
color.label,
)}
aria-label={`Stage direction: ${segment.tags.join(", ")}`}
>
{segment.tags.join(", ")}
</span>
<span
className={cn(
"underline-offset-4",
color.text,
!pacingActive && "underline",
color.underline,
)}
>
{renderSpokenText(
segment,
segmentPacing,
pacingActive ? cn("underline", color.underline) : undefined,
)}
</span>
</span>
);
})}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<GaugeIcon className="size-3.5" />
<span>Constant scroll speed · {scrollSpeed} px/s</span>
</div>
</CardContent>
</Card>
);
}