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>
373 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|