"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import VerdictChip from "@/components/VerdictChip"; import MomentMarks from "@/components/MomentMarks"; import NoteField from "@/components/NoteField"; import type { Rendition, Variant } from "@/lib/browse"; import type { NoteMap } from "@/lib/notes"; import { headToHead, summarise, type Compare, type CompareChoice, type CompareDoc, } from "@/lib/compare-types"; import { fmtBytes, fmtDur } from "@/lib/format"; // Two players, side by side, over the SAME cut. // // The files play through /api/mix/media, which already does Range/206 -- the // whole point of this page is jumping to 0:42 in both and listening, and // without 206 every seek on a 66 MB render restarts the download. // // "Match time" rather than a locked sync: the two files are frequently // DIFFERENT LENGTHS (that is often what is being judged), so a hard lock would // drift and then clamp at the shorter one's end. Copying the playhead across on // demand is what the comparison actually needs, and it cannot lie. // // --------------------------------------------------------------------------- // BLIND MODE // // Sighted comparison is the default and is unchanged. Blind is a toggle, and // when it is on the bench must not name either file ANYWHERE the eye or the // devtools can reach it: // // * the srcs are opaque tokens (/api/browse/blind?t=), minted server-side // * the side order is chosen once, on the server, when the pairing is minted // -- re-shuffling on a client re-render would swap the panes mid-listen // * no data-rel, no tag, no byte size // * NO NATIVE CONTROLS. `controls` draws the duration in the scrubber, and // two files of visibly different length are not blind -- the length is // frequently the very thing being judged. So blind panes get a transport of // their own, with one shared scale (the shorter of the two) and no numbers. // * the verdict chip, the marks and the note field are hidden, because every // one of them names the file it writes to // // Reveal happens only after a call is recorded, and the record is written // before the mapping is returned -- so there is no ordering in which a label // reaches the screen before the observation is on disk. // --------------------------------------------------------------------------- const mediaUrl = (label: string) => `/api/mix/media?path=${encodeURIComponent(label)}`; const blindUrl = (token: string) => `/api/browse/blind?t=${encodeURIComponent(token)}`; type Pairing = { pairing: string; cut: string; left: string; right: string }; type Outcome = { reveal: { left: string; right: string }; head: { a: number; b: number; ties: number }; winner: CompareChoice; }; type Props = { song: string; cut: string; shipped: Rendition | null; variants: Variant[]; durations: Record; initialVariant: string | null; notes: NoteMap; compares: CompareDoc; }; export default function CutBench({ song, cut, shipped, variants, durations, initialVariant, notes, compares, }: Props) { const router = useRouter(); const live = variants.filter((v) => !v.retired); const retired = variants.filter((v) => v.retired); const [pick, setPick] = useState( initialVariant && variants.some((v) => v.rel === initialVariant) ? initialVariant : (live[0]?.rel ?? null), ); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [done, setDone] = useState(null); const [blind, setBlind] = useState(false); const [pairing, setPairing] = useState(null); const [outcome, setOutcome] = useState(null); const [rows, setRows] = useState(compares.compares); const a = useRef(null); const b = useRef(null); const chosen = variants.find((v) => v.rel === pick) ?? null; const copyTime = (from: HTMLVideoElement | null, to: HTMLVideoElement | null) => { if (!from || !to) return; // Clamp: the other cut is often shorter, and setting currentTime past the // end leaves the element in a state where nothing plays and nothing says so. to.currentTime = Math.min(from.currentTime, Math.max(0, (to.duration || 0) - 0.05)); }; const act = async (action: "promote" | "retire" | "restore", rel: string) => { setBusy(rel); setError(null); setDone(null); try { const res = await fetch("/api/browse/promote", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ song, rel, action }), cache: "no-store", }); const j = (await res.json()) as { error?: string; moved?: { from: string; to: string }[] }; if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setDone((j.moved ?? []).map((m) => `${m.from} → ${m.to}`).join(" · ")); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); } }; // --- the pair under comparison, and its history -------------------------- const pair = shipped && chosen ? { left: shipped.rel, right: chosen.rel } : null; const history = pair ? headToHead({ version: 1, compares: rows }, pair.left, pair.right) : null; const mint = useCallback(async () => { if (!pair) return; setError(null); setOutcome(null); setPairing(null); try { const res = await fetch("/api/browse/compare", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ song, cut, left: pair.left, right: pair.right, mint: true }), cache: "no-store", }); const j = (await res.json()) as Pairing & { error?: string }; if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setPairing(j); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, [song, cut, pair?.left, pair?.right]); // eslint-disable-line react-hooks/exhaustive-deps // A new pairing whenever the pair changes while blind, and none at all while // sighted -- so switching variants mid-session cannot leave the old tokens on // screen under the new labels. useEffect(() => { if (!blind || !pair) { setPairing(null); setOutcome(null); return; } void mint(); }, [blind, mint, pair?.left, pair?.right]); // eslint-disable-line react-hooks/exhaustive-deps const decide = async (winner: CompareChoice) => { setError(null); try { const body = pairing ? { pairing: pairing.pairing, winner } : { song, cut, left: pair!.left, right: pair!.right, winner }; const res = await fetch("/api/browse/compare", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); const j = (await res.json()) as { error?: string; compare: Compare; reveal?: { left: string; right: string }; head: { a: number; b: number; ties: number }; }; if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setRows((r) => [...r, j.compare]); if (pairing) { setOutcome({ reveal: j.reveal ?? { left: j.compare.left, right: j.compare.right }, head: j.head, winner, }); } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; /** Apply the winner as a standing verdict. A SECOND, explicit click. */ const applyKeep = async (rel: string) => { setBusy(rel); setError(null); try { const res = await fetch("/api/browse/verdict", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ song, rel, verdict: "keep", note: `won a blind A/B against the other ${cut} (${summarise(outcome!.head)})`, }), cache: "no-store", }); const j = (await res.json()) as { error?: string }; if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); } }; // One scale for both blind transports: the shorter of the pair. Different // maxima would put the duration back on screen as the width of a slider. const commonMax = pair ? Math.max(0.1, Math.min(durations[pair.left] ?? 0, durations[pair.right] ?? 0) || 0.1) : 0.1; return (
{!pair && ( a blind comparison needs a shipped cut and a variant to face it )} {history && (history.a || history.b || history.ties) > 0 && ( shipped {summarise(history)} against {chosen?.tag} )}
{blind && !pairing ? ( // Minting failed or has not landed. The one thing this mode may never // do is show labelled panes while the toggle says the labels are // hidden, so it shows nothing instead.
{error ?? "minting a pairing…"}
) : blind && pairing ? ( <>
copyTime(b.current, a.current)} revealed={outcome?.reveal.left ?? null} /> copyTime(a.current, b.current)} revealed={outcome?.reveal.right ?? null} />
{outcome ? ( <> recorded:{" "} {outcome.winner === "tie" ? "no difference" : outcome.winner === "left" ? outcome.reveal.left : outcome.reveal.right} {summarise(outcome.head)} so far {outcome.winner !== "tie" && ( )} {/* One A/B preference is not the same claim as "this ships", so the keep is offered rather than derived. */} a compare is one dated observation; a verdict is a standing judgement — neither is written from the other ) : ( <> which is better? {( [ ["left", "A"], ["tie", "no difference"], ["right", "B"], ] as [CompareChoice, string][] ).map(([w, label]) => ( ))} labels appear once the call is recorded )} {error && {error}}
) : ( <>
copyTime(b.current, a.current)} matchLabel="← match variant" notes={notes} /> copyTime(a.current, b.current)} matchLabel="match shipped →" notes={notes} />
{pair && (
sighted call {( [ ["left", `shipped ${cut}`], ["tie", "no difference"], ["right", chosen!.tag], ] as [CompareChoice, string][] ).map(([w, label]) => ( ))} recorded as sighted — a call made with the labels showing is different evidence
)} )} {live.length > 0 && (
variants {live.map((v) => ( ))}
)} {chosen && !blind && (
{/* Say what promote DOES before it is pressed. It is reversible only because the current cut is moved aside rather than overwritten, and that is worth stating where the button is. */} the current {cut}.mp4 moves into variants/ first — nothing is overwritten, nothing is deleted {done && {done}} {error && {error}}
)} {retired.length > 0 && !blind && (
retired {retired.map((v) => ( ))}
)} {rows.length > 0 && !blind && (
{rows.length} recorded comparison{rows.length > 1 ? "s" : ""} on this song
    {[...rows].reverse().map((c, i) => (
  • {c.cut} ·{" "} {c.winner === "tie" ? ( no difference between {c.left} and {c.right} ) : ( {c.winner === "left" ? c.left : c.right} {" "} beat {c.winner === "left" ? c.right : c.left} )}{" "} · {c.blind ? "blind" : "sighted"} · {c.at.slice(0, 10)}
  • ))}
)}
); } // --------------------------------------------------------------------------- // A blind pane: a picture, a transport with no numbers, and nothing else. // --------------------------------------------------------------------------- function BlindPane({ title, src, videoRef, max, onMatch, revealed, }: { title: string; src: string; videoRef: React.RefObject; max: number; onMatch: () => void; revealed: string | null; }) { const [playing, setPlaying] = useState(false); const [at, setAt] = useState(0); // The playhead is polled while playing rather than subscribed to timeupdate // on the whole bench: this is the one place a number is drawn, and it is // drawn as a slider position, not as a figure. useEffect(() => { if (!playing) return; const t = setInterval(() => setAt(videoRef.current?.currentTime ?? 0), 200); return () => clearInterval(t); }, [playing, videoRef]); const seek = (t: number) => { if (videoRef.current) videoRef.current.currentTime = t; setAt(t); }; return (
{title} {revealed ? ( {revealed} ) : ( unlabelled )}
); } function Pane({ title, sub, rendition, song, durations, videoRef, onMatch, matchLabel, notes, }: { title: string; sub: string; rendition: Rendition | null; song: string; durations: Record; videoRef: React.RefObject; onMatch: () => void; matchLabel: string; notes: NoteMap; }) { return (
{title} {sub} {rendition && ( {fmtDur(durations[rendition.rel] ?? 0)} )} {rendition && ( {fmtBytes(rendition.size)} )}
{rendition ? ( <>
); }