"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import AnchoredNotes from "@/components/notes/AnchoredNotes"; import { NotesProvider, useSharedNotes } from "@/components/notes/NotesProvider"; import TimedNotes from "@/components/notes/TimedNotes"; import { badgeVariants, type BadgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { fmtAgo } from "@/lib/format"; // The takes of one project, a group at a time, every preview on screen at once. // // ONE AUDIO SOURCE. Every preview is muted except the one being listened to: // the "listen" toggle picks it, and so does pressing play (or unmuting) on a // single preview -- the last thing you reached for is what you hear. "Play all // from start" keeps whichever is being listened to, so a group can be watched // in sync against one soundtrack, or in silence. // // Buttons stay disabled until mount, as VerdictChip's do: a click before // hydration sends nothing, and a verdict lost that way looks like one never // given. export type Take = { id: string; group: string; order: number; label: string; kind: "reference" | "similar" | "different"; summary: string; changes: string[]; preview: string; seconds: number | null; builtAt: string | null; previewSize: number | null; previewMtimeMs: number | null; }; export type Verdict = "like" | "maybe" | "no"; export type TakeVerdict = { verdict: Verdict | null; note: string; at: string }; const KIND_TONE: Record = { reference: "on", similar: "info", different: "meter", }; // Literal class strings, as VerdictChip's are: Tailwind finds classes by // reading the source, so a class assembled from a variable is never generated. const TONES = { good: { on: "border-[var(--color-good)] bg-[color-mix(in_srgb,var(--color-good)_18%,transparent)] text-[var(--color-good)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-good)] hover:text-[var(--color-good)]", }, dirty: { on: "border-[var(--color-dirty)] bg-[color-mix(in_srgb,var(--color-dirty)_18%,transparent)] text-[var(--color-dirty)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-dirty)] hover:text-[var(--color-dirty)]", }, bad: { on: "border-[var(--color-bad)] bg-[color-mix(in_srgb,var(--color-bad)_18%,transparent)] text-[var(--color-bad)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-bad)] hover:text-[var(--color-bad)]", }, sel: { on: "border-[var(--color-sel)] bg-[color-mix(in_srgb,var(--color-sel)_18%,transparent)] text-[var(--color-sel)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-sel)] hover:text-[var(--color-sel)]", }, } as const; type Tone = keyof typeof TONES; const tone = (t: Tone, on: boolean) => (on ? TONES[t].on : TONES[t].off); const VERDICTS: { id: Verdict; label: string; tone: Tone }[] = [ { id: "like", label: "Like", tone: "good" }, { id: "maybe", label: "Maybe", tone: "dirty" }, { id: "no", label: "No", tone: "bad" }, ]; const previewSrc = (project: string, t: Take) => `/api/report/takes/preview?${new URLSearchParams({ project, take: t.id, ...(t.previewMtimeMs != null ? { v: String(t.previewMtimeMs) } : {}), })}`; export default function TakesBench({ project, groups, initial, }: { project: string; groups: { group: string; takes: Take[] }[]; initial: Record; }) { const [verdicts, setVerdicts] = useState(initial); const [listen, setListen] = useState(null); const [ready, setReady] = useState(false); const videos = useRef(new Map()); const listenRef = useRef(null); // True while "play all" is starting its videos, so their play events do not // each claim the audio. const starting = useRef(false); useEffect(() => setReady(true), []); useEffect(() => { listenRef.current = listen; for (const [id, v] of videos.current) v.muted = id !== listen; }, [listen]); const els = (g: { takes: Take[] }) => g.takes.map((t) => videos.current.get(t.id)).filter((v): v is HTMLVideoElement => !!v); const playAll = async (g: { takes: Take[] }) => { starting.current = true; const list = els(g); for (const v of list) { v.pause(); v.currentTime = 0; } await Promise.allSettled(list.map((v) => v.play())); starting.current = false; }; const save = async (take: string, patch: { verdict?: Verdict | null; note?: string }) => { const r = await fetch("/api/report/takes/verdict", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project, take, ...patch }), cache: "no-store", }); const j = (await r.json()) as { error?: string; verdicts?: Record }; if (!r.ok || !j.verdicts) throw new Error(j.error ?? `HTTP ${r.status}`); setVerdicts(j.verdicts); }; return (
{groups.map((g) => { const tally = VERDICTS.map((v) => [v.id, g.takes.filter((t) => verdicts[t.id]?.verdict === v.id).length] as const) .filter(([, n]) => n > 0) .map(([id, n]) => `${n} ${id}`) .join(" · "); return (

{g.group} — {g.takes.length}

{tally && {tally}}
{g.takes.map((t) => ( setListen((cur) => (cur === t.id ? null : t.id))} videoRef={(el) => { if (el) { el.muted = t.id !== listenRef.current; videos.current.set(t.id, el); } else { videos.current.delete(t.id); } }} onPlay={() => { if (!starting.current && listenRef.current !== t.id) setListen(t.id); }} onUnmute={() => { if (listenRef.current !== t.id) setListen(t.id); }} save={(patch) => save(t.id, patch)} /> ))}
); })}
); } function TakeCard({ project, take: t, src, verdict, listening, ready, onListen, videoRef, onPlay, onUnmute, save, }: { project: string; take: Take; src: string; verdict: TakeVerdict | null; listening: boolean; ready: boolean; onListen: () => void; videoRef: (el: HTMLVideoElement | null) => void; onPlay: () => void; onUnmute: () => void; save: (patch: { verdict?: Verdict | null; note?: string }) => Promise; }) { const notes = useSharedNotes({ project }); // The element, for the timed notes; the parent's callback (audio routing) // still gets it. Stable, so it runs once per mount, not once per render. const [el, setEl] = useState(null); const parentRef = useRef(videoRef); parentRef.current = videoRef; const bindVideo = useCallback((node: HTMLVideoElement | null) => { parentRef.current(node); setEl(node); }, []); const [note, setNote] = useState(verdict?.note ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const saved = verdict?.note ?? ""; // Adopt a server value that moved underneath (another tab, a refresh). useEffect(() => setNote(verdict?.note ?? ""), [verdict?.note]); const run = async (patch: { verdict?: Verdict | null; note?: string }) => { setBusy(true); setError(null); try { await save(patch); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const commitNote = () => { if (note.replace(/\s+$/, "") !== saved) void run({ note }); }; const current = verdict?.verdict ?? null; const hue = VERDICTS.find((v) => v.id === current)?.tone; return (
{t.previewSize != null ? (
); }