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 Link from "next/link";
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 { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
import { SpoilerDescription, SpoilerGate } from "@/components/frontend/voiceover/SpoilerGate";
import { getScriptForUser } from "@/lib/voiceover";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
@ -38,6 +39,7 @@ export default async function VoiceoverScriptPage({
if (!script) notFound();
return (
<SpoilerGate spoiler={script.spoiler}>
<div className="flex min-w-0 flex-col gap-6 p-5">
<Link
href="/voiceover"
@ -51,15 +53,27 @@ export default async function VoiceoverScriptPage({
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-lg font-semibold">{script.title}</h1>
{script.description?.trim() && (
<p className="text-sm text-foreground/80">{script.description}</p>
)}
<SpoilerDescription spoiler={script.spoiler} description={script.description} />
<p className="text-sm text-muted-foreground">
Read the script at your pace, then submit one clear take for review.
</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">
@ -67,8 +81,8 @@ export default async function VoiceoverScriptPage({
<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.
This script may include, infer, or otherwise expose campaign spoilers. Read on
only if you are comfortable with that.
</p>
</div>
</CardContent>
@ -77,5 +91,6 @@ export default async function VoiceoverScriptPage({
<VoiceoverRecorder script={script} />
</div>
</SpoilerGate>
);
}

View file

@ -8,6 +8,7 @@ import {
ArrowRightIcon,
CheckCircle2Icon,
Clock3Icon,
EyeOffIcon,
MicIcon,
SparklesIcon,
TriangleAlertIcon,
@ -18,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
import { cn } from "@/lib/utils";
import { redactText } from "@/lib/redaction";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata: Metadata = {
@ -32,6 +34,7 @@ const submissionStatusClasses: Record<string, string> = {
};
function ScriptAvailability({ status }: { readonly status: string }) {
const isDraft = status === "draft";
const isOpen = status === "open";
return (
@ -39,11 +42,19 @@ function ScriptAvailability({ status }: { readonly status: string }) {
variant="outline"
className={cn(
"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" />}
{isOpen ? "Open" : "Closed"}
{isDraft ? (
<EyeOffIcon className="size-3" />
) : isOpen ? (
<CheckCircle2Icon className="size-3" />
) : (
<Clock3Icon className="size-3" />
)}
{isDraft ? "Draft" : isOpen ? "Open" : "Closed"}
</Badge>
);
}
@ -81,15 +92,23 @@ function SpoilerBadge() {
}
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
const isDraft = script.status === "draft";
const isOpen = script.status === "open";
const isRecordable = isOpen;
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">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-2">
<CardTitle className="min-w-0">
{isOpen ? (
{isRecordable || isDraft ? (
<Link
href={`/voiceover/${script.id}`}
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>
<CardDescription>
{script.description?.trim() || `Voiceover script #${script.id}`}
{(script.spoiler ? redactText(script.description) : script.description)?.trim() ||
`Voiceover script #${script.id}`}
</CardDescription>
</div>
<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} />
</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">
<Link href={`/voiceover/${script.id}`}>
<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";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import {
CheckCircle2Icon,
LockKeyholeIcon,
MicIcon,
SparklesIcon,
TriangleAlertIcon,
} from "lucide-react";
import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react";
import { toast } from "sonner";
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 { Card, CardContent } from "@/components/ui/card";
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 {
VoiceCapture,
@ -33,104 +16,13 @@ import {
} from "@/components/frontend/voiceover/VoiceCapture";
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 }) {
const router = useRouter();
const isOpen = script.status === "open";
const [spoilerAcknowledged, setSpoilerAcknowledged] = useState(false);
const { acknowledged: spoilerAcknowledged } = useSpoilerAcknowledgment();
if (script.spoiler && !spoilerAcknowledged) {
return (
<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>
);
return null;
}
async function handleSubmitTake(

View file

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

View file

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