1
0
Fork 0

feat(voiceover): spoiler gate, pacing pattern, and recorder updates

Recorder and pages gain a spoiler gate; voiceover lib adds clause-pause pacing support.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:37:12 -04:00
parent 982c33f4f3
commit 4d08122464
6 changed files with 263 additions and 162 deletions

View file

@ -4,10 +4,11 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ArrowLeftIcon, MicIcon, TriangleAlertIcon } from "lucide-react"; import { ArrowLeftIcon, EyeOffIcon, MicIcon, TriangleAlertIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder"; import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
import { SpoilerDescription, SpoilerGate } from "@/components/frontend/voiceover/SpoilerGate";
import { getScriptForUser } from "@/lib/voiceover"; import { getScriptForUser } from "@/lib/voiceover";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
@ -38,44 +39,58 @@ export default async function VoiceoverScriptPage({
if (!script) notFound(); if (!script) notFound();
return ( return (
<div className="flex min-w-0 flex-col gap-6 p-5"> <SpoilerGate spoiler={script.spoiler}>
<Link <div className="flex min-w-0 flex-col gap-6 p-5">
href="/voiceover" <Link
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" href="/voiceover"
> className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
<ArrowLeftIcon className="size-3" /> >
Voiceover scripts <ArrowLeftIcon className="size-3" />
</Link> Voiceover scripts
</Link>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" /> <MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<h1 className="text-lg font-semibold">{script.title}</h1> <h1 className="text-lg font-semibold">{script.title}</h1>
{script.description?.trim() && ( <SpoilerDescription spoiler={script.spoiler} description={script.description} />
<p className="text-sm text-foreground/80">{script.description}</p> <p className="text-sm text-muted-foreground">
)} Read the script at your pace, then submit one clear take for review.
<p className="text-sm text-muted-foreground"> </p>
Read the script at your pace, then submit one clear take for review. </div>
</p>
</div> </div>
{script.status === "draft" && (
<Card className="border-dashed border-violet-500/50">
<CardContent className="flex items-start gap-3 p-4 text-sm">
<EyeOffIcon className="mt-0.5 size-4 shrink-0 text-violet-300" />
<div className="flex flex-col gap-1">
<p className="font-medium text-violet-300">Draft preview</p>
<p className="text-muted-foreground">
This script is still a draft and is hidden from other users.
</p>
</div>
</CardContent>
</Card>
)}
{script.spoiler && (
<Card className="border-amber-500/40">
<CardContent className="flex items-start gap-3 p-4 text-sm">
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="flex flex-col gap-1">
<p className="font-medium text-amber-300">Potential campaign spoilers ahead</p>
<p className="text-muted-foreground">
This script may include, infer, or otherwise expose campaign spoilers. Read on
only if you are comfortable with that.
</p>
</div>
</CardContent>
</Card>
)}
<VoiceoverRecorder script={script} />
</div> </div>
</SpoilerGate>
{script.spoiler && (
<Card className="border-amber-500/40">
<CardContent className="flex items-start gap-3 p-4 text-sm">
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="flex flex-col gap-1">
<p className="font-medium text-amber-300">Potential campaign spoilers ahead</p>
<p className="text-muted-foreground">
This script may include, infer, or otherwise expose campaign spoilers. Read on only
if you are comfortable with that.
</p>
</div>
</CardContent>
</Card>
)}
<VoiceoverRecorder script={script} />
</div>
); );
} }

View file

@ -8,6 +8,7 @@ import {
ArrowRightIcon, ArrowRightIcon,
CheckCircle2Icon, CheckCircle2Icon,
Clock3Icon, Clock3Icon,
EyeOffIcon,
MicIcon, MicIcon,
SparklesIcon, SparklesIcon,
TriangleAlertIcon, TriangleAlertIcon,
@ -18,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover"; import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { redactText } from "@/lib/redaction";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -32,6 +34,7 @@ const submissionStatusClasses: Record<string, string> = {
}; };
function ScriptAvailability({ status }: { readonly status: string }) { function ScriptAvailability({ status }: { readonly status: string }) {
const isDraft = status === "draft";
const isOpen = status === "open"; const isOpen = status === "open";
return ( return (
@ -39,11 +42,19 @@ function ScriptAvailability({ status }: { readonly status: string }) {
variant="outline" variant="outline"
className={cn( className={cn(
"shrink-0 gap-1", "shrink-0 gap-1",
isOpen ? "border-emerald-500/40 text-emerald-400" : "text-muted-foreground", isOpen && "border-emerald-500/40 text-emerald-400",
isDraft && "border-violet-500/50 text-violet-300",
!isOpen && !isDraft && "text-muted-foreground",
)} )}
> >
{isOpen ? <CheckCircle2Icon className="size-3" /> : <Clock3Icon className="size-3" />} {isDraft ? (
{isOpen ? "Open" : "Closed"} <EyeOffIcon className="size-3" />
) : isOpen ? (
<CheckCircle2Icon className="size-3" />
) : (
<Clock3Icon className="size-3" />
)}
{isDraft ? "Draft" : isOpen ? "Open" : "Closed"}
</Badge> </Badge>
); );
} }
@ -81,15 +92,23 @@ function SpoilerBadge() {
} }
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) { function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
const isDraft = script.status === "draft";
const isOpen = script.status === "open"; const isOpen = script.status === "open";
const isRecordable = isOpen;
return ( return (
<Card className={cn("flex h-full flex-col", !isOpen && "opacity-75")}> <Card
className={cn(
"flex h-full flex-col",
isDraft && "border-dashed border-violet-500/50",
!isOpen && !isDraft && "opacity-75",
)}
>
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<CardTitle className="min-w-0"> <CardTitle className="min-w-0">
{isOpen ? ( {isRecordable || isDraft ? (
<Link <Link
href={`/voiceover/${script.id}`} href={`/voiceover/${script.id}`}
className="rounded-sm outline-none transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring" className="rounded-sm outline-none transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
@ -101,7 +120,8 @@ function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
)} )}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{script.description?.trim() || `Voiceover script #${script.id}`} {(script.spoiler ? redactText(script.description) : script.description)?.trim() ||
`Voiceover script #${script.id}`}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
@ -129,7 +149,11 @@ function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
<SubmissionStatuses script={script} /> <SubmissionStatuses script={script} />
</div> </div>
{isOpen ? ( {isDraft ? (
<p className="mt-auto text-sm text-violet-300/90">
Draft preview: this script is still hidden from other users.
</p>
) : isRecordable ? (
<Button asChild className="mt-auto w-full sm:w-fit"> <Button asChild className="mt-auto w-full sm:w-fit">
<Link href={`/voiceover/${script.id}`}> <Link href={`/voiceover/${script.id}`}>
<MicIcon data-icon="inline-start" /> <MicIcon data-icon="inline-start" />

View file

@ -0,0 +1,162 @@
"use client";
import { useRouter } from "next/navigation";
import { createContext, useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { TriangleAlertIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { redactText } from "@/lib/redaction";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
const HOLD_CONFIRM_MS = 1000;
interface SpoilerAcknowledgment {
readonly acknowledged: boolean;
readonly acknowledge: () => void;
}
// Default to acknowledged so consumers outside a gate are never blocked.
const SpoilerContext = createContext<SpoilerAcknowledgment>({
acknowledged: true,
acknowledge: () => undefined,
});
export function useSpoilerAcknowledgment(): SpoilerAcknowledgment {
return useContext(SpoilerContext);
}
function HoldToConfirmButton({
onConfirm,
children,
}: {
readonly onConfirm: () => void;
readonly children: ReactNode;
}) {
const [holding, setHolding] = useState(false);
const timerRef = useRef<number | null>(null);
const doneRef = useRef(false);
const cancel = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setHolding(false);
};
const begin = () => {
if (timerRef.current !== null || doneRef.current) return;
setHolding(true);
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
doneRef.current = true;
onConfirm();
}, HOLD_CONFIRM_MS);
};
return (
<button
type="button"
className={cn(buttonVariants(), "relative touch-none select-none overflow-hidden")}
onPointerDown={(event) => {
if (event.button === 0) begin();
}}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) begin();
}}
onKeyUp={(event) => {
if (event.key === " " || event.key === "Enter") cancel();
}}
onBlur={cancel}
>
<span aria-hidden="true" className="absolute inset-0">
<span
className={cn(
"absolute inset-0 origin-left bg-primary-foreground/40 transition-transform ease-linear",
holding ? "scale-x-100 duration-[1000ms]" : "scale-x-0 duration-0",
)}
/>
</span>
<span className="relative">{children}</span>
</button>
);
}
export function SpoilerGate({
spoiler,
children,
}: {
readonly spoiler: boolean;
readonly children: ReactNode;
}) {
const router = useRouter();
const [acknowledged, setAcknowledged] = useState(false);
const value = useMemo(
() => ({
acknowledged: !spoiler || acknowledged,
acknowledge: () => setAcknowledged(true),
}),
[acknowledged, spoiler],
);
return (
<SpoilerContext.Provider value={value}>
{children}
{spoiler && !acknowledged && (
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<TriangleAlertIcon className="size-5 text-amber-400" />
Potential campaign spoilers
</AlertDialogTitle>
<AlertDialogDescription>
This recording page may include, infer, or otherwise expose campaign spoilers.
Continue only if you are comfortable with that. Press and hold the confirmation
button for one second to continue.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => router.push("/voiceover")}>
Go back
</AlertDialogCancel>
<HoldToConfirmButton onConfirm={value.acknowledge}>I understand</HoldToConfirmButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</SpoilerContext.Provider>
);
}
/**
* Renders the script description, redacted until the spoiler gate is
* acknowledged (plain text for non-spoiler scripts).
*/
export function SpoilerDescription({
spoiler,
description,
}: {
readonly spoiler: boolean;
readonly description: string | null;
}) {
const { acknowledged } = useSpoilerAcknowledgment();
const text = spoiler && !acknowledged && description ? redactText(description) : description;
if (!text?.trim()) return null;
return <p className="text-sm text-foreground/80">{text}</p>;
}

View file

@ -1,31 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react";
CheckCircle2Icon,
LockKeyholeIcon,
MicIcon,
SparklesIcon,
TriangleAlertIcon,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions"; import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import type { VoiceoverScriptView } from "@/lib/voiceover"; import type { VoiceoverScriptView } from "@/lib/voiceover";
import { cn } from "@/lib/utils"; import { useSpoilerAcknowledgment } from "@/components/frontend/voiceover/SpoilerGate";
import { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader"; import { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader";
import { import {
VoiceCapture, VoiceCapture,
@ -33,104 +16,13 @@ import {
} from "@/components/frontend/voiceover/VoiceCapture"; } from "@/components/frontend/voiceover/VoiceCapture";
import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes"; import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes";
const HOLD_CONFIRM_MS = 1000;
function HoldToConfirmButton({
onConfirm,
children,
}: {
readonly onConfirm: () => void;
readonly children: ReactNode;
}) {
const [holding, setHolding] = useState(false);
const timerRef = useRef<number | null>(null);
const doneRef = useRef(false);
const cancel = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setHolding(false);
}, []);
const begin = useCallback(() => {
if (timerRef.current !== null || doneRef.current) return;
setHolding(true);
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
doneRef.current = true;
onConfirm();
}, HOLD_CONFIRM_MS);
}, [onConfirm]);
useEffect(() => {
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, []);
return (
<button
type="button"
className={cn(buttonVariants(), "relative touch-none select-none overflow-hidden")}
onPointerDown={(event) => {
if (event.button === 0) begin();
}}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) begin();
}}
onKeyUp={(event) => {
if (event.key === " " || event.key === "Enter") cancel();
}}
onBlur={cancel}
>
<span aria-hidden="true" className="absolute inset-0">
<span
className={cn(
"absolute inset-0 origin-left bg-primary-foreground/40 transition-transform ease-linear",
holding ? "scale-x-100 duration-[1000ms]" : "scale-x-0 duration-0",
)}
/>
</span>
<span className="relative">{children}</span>
</button>
);
}
export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) { export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) {
const router = useRouter(); const router = useRouter();
const isOpen = script.status === "open"; const isOpen = script.status === "open";
const [spoilerAcknowledged, setSpoilerAcknowledged] = useState(false); const { acknowledged: spoilerAcknowledged } = useSpoilerAcknowledgment();
if (script.spoiler && !spoilerAcknowledged) { if (script.spoiler && !spoilerAcknowledged) {
return ( return null;
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<TriangleAlertIcon className="size-5 text-amber-400" />
Potential campaign spoilers
</AlertDialogTitle>
<AlertDialogDescription>
This recording page may include, infer, or otherwise expose campaign spoilers.
Continue only if you are comfortable with that. Press and hold the confirmation button
for one second to continue.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => router.push("/voiceover")}>Go back</AlertDialogCancel>
<HoldToConfirmButton onConfirm={() => setSpoilerAcknowledged(true)}>
I understand
</HoldToConfirmButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
} }
async function handleSubmitTake( async function handleSubmitTake(

View file

@ -5,7 +5,7 @@ export interface VoiceoverSegment {
readonly directive?: string; readonly directive?: string;
} }
const TAG_PATTERN = /\[([a-z][a-z\s,-]*)\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi; const TAG_PATTERN = /\[([a-z][a-z\s,-]*)?\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi;
function normalizeScript(script: string): string { function normalizeScript(script: string): string {
return script return script
@ -73,9 +73,14 @@ export function parseEmotionTags(script: string): VoiceoverSegment[] {
flush(source.slice(cursor, matchIndex), false); flush(source.slice(cursor, matchIndex), false);
} }
const emotive = normalizeTag(match[1] ?? ""); if (match[1] !== undefined) {
if (emotive) { const emotive = normalizeTag(match[1]);
pendingTags.push(emotive); if (emotive) {
pendingTags.push(emotive);
} else {
// Empty [] is an explicit reset: the next text renders plain again.
pendingTags = [];
}
} }
const sfx = normalizeTag(match[2] ?? ""); const sfx = normalizeTag(match[2] ?? "");

View file

@ -1,4 +1,5 @@
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types"; import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
import { isSuperuser } from "@/utils/access-control/hasPermission";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>; type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
@ -87,10 +88,11 @@ export async function listScriptsForUser(
payload: PayloadType, payload: PayloadType,
user: User, user: User,
): Promise<VoiceoverScriptView[]> { ): Promise<VoiceoverScriptView[]> {
const canSeeDrafts = await isSuperuser(payload, user);
const [scriptsRes, submissionsRes] = await Promise.all([ const [scriptsRes, submissionsRes] = await Promise.all([
payload.find({ payload.find({
collection: "voice-scripts", collection: "voice-scripts",
where: { status: { not_equals: "draft" } }, where: canSeeDrafts ? undefined : { status: { not_equals: "draft" } },
sort: "-createdAt", sort: "-createdAt",
limit: 0, limit: 0,
depth: 0, depth: 0,
@ -122,7 +124,8 @@ export async function getScriptForUser(
overrideAccess: true, overrideAccess: true,
}) })
.catch(() => null); .catch(() => null);
if (!script || script.status === "draft") return null; if (!script) return null;
if (script.status === "draft" && !(await isSuperuser(payload, user))) return null;
const submissionsRes = await payload.find({ const submissionsRes = await payload.find({
collection: "voice-submissions", collection: "voice-submissions",