"use client"; import { useState } from "react"; import CopyButton from "@/components/CopyButton"; import type { NoteMap } from "@/lib/notes"; // Timestamped marks on a rendered cut. // // The thing that makes these worth capturing is not the timestamp -- it is what // the server can do with it. A mark at 0:42 on a shipped mp4 resolves, through // the plan, to the clip playing there, the episode it was cut from, and a link // that lands three seconds before that moment in the archive. That join is the // one the tree could never make on its own, and it turns "the kit is late // around forty seconds" into something actionable by somebody who was not here. // // A mark is just a note with a `moment:@` target, so it costs no new // storage and it follows the file through a promote like every other note. const ts = (t: number) => { const m = Math.floor(t / 60); const s = t - m * 60; return `${m}:${s.toFixed(1).padStart(4, "0")}`; }; type Mark = { t: number; note: string }; export default function MomentMarks({ song, rel, initial, getTime, onSeek, }: { song: string; rel: string; initial: NoteMap; /** The player's current position, read at click time. */ getTime: () => number | null; onSeek: (t: number) => void; }) { const prefix = `moment:${rel}@`; const [marks, setMarks] = useState(() => Object.entries(initial) .filter(([k]) => k.startsWith(prefix)) .map(([k, v]) => ({ t: Number(k.slice(prefix.length)), note: v.note })) .filter((m) => Number.isFinite(m.t)) .sort((a, b) => a.t - b.t), ); const [pending, setPending] = useState<{ t: number; note: string } | null>(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const write = async (t: number, note: string) => { setBusy(true); setError(null); try { const res = await fetch("/api/browse/notes", { method: "POST", headers: { "content-type": "application/json" }, // Fixed to 2dp: the key IS the timestamp, so it has to round-trip // through a string identically or the same mark saves twice. body: JSON.stringify({ song, target: `${prefix}${t.toFixed(2)}`, note }), cache: "no-store", }); const j = await res.json(); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setMarks((prev) => { const rest = prev.filter((m) => m.t.toFixed(2) !== t.toFixed(2)); return note.trim() ? [...rest, { t: +t.toFixed(2), note }].sort((a, b) => a.t - b.t) : rest.sort((a, b) => a.t - b.t); }); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const mark = () => { const t = getTime(); if (t === null) { setError("nothing is playing to mark"); return; } setPending({ t: +t.toFixed(2), note: "" }); }; return (
{marks.length > 0 && ( )} {error && {error}}
{pending && (
{ts(pending.t)} setPending({ ...pending, note: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter" && pending.note.trim()) { void write(pending.t, pending.note); setPending(null); } if (e.key === "Escape") setPending(null); }} placeholder="what is happening here" className="min-w-[16rem] flex-1 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" />
)} {marks.length > 0 && (
    {marks.map((m) => (
  • {m.note}
  • ))}
)}
); }