"use client"; import { useCallback, useEffect, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import type { Anchor, MomentResolved, Note } from "@/lib/annotations/types"; import type { NotesTarget, UseNotes } from "@/lib/annotations/useNotes"; import { useSharedNotes } from "./NotesProvider"; import { NoteComposer, NoteThread } from "./NoteThread"; // Notes at a SECOND of a rendered video, in the notes.json of whatever the // video belongs to (a report-video project, an article). The song tool's // MomentMarks keeps its own store; this is the same idea bound to // lib/annotations, so an agent reads it back with everything else. // // `n` (with the video focused) or "Mark" opens a note at the playhead. When // the video is a report project's build, the second is RESOLVED at write time // (/api/report/moment: the entry on screen, its title and quote, the source // second and its archive link) and stored in the note -- a rebuild moves // entries, and the agent must see what was on screen when the key was pressed. // A file with no build schedule keeps `t` alone. const ts = (t: number) => { const m = Math.floor(t / 60); const s = t - m * 60; return `${m}:${s.toFixed(1).padStart(4, "0")}`; }; type MomentAnchor = Extract; const isMomentOn = (file: string) => (n: Note): n is Note & { anchor: MomentAnchor } => n.anchor.kind === "moment" && n.anchor.file === file; export default function TimedNotes({ notes, file, video, take, resolveProject, showErrors = true, }: { notes: UseNotes; /** The file's path as the anchor stores it: project-relative (`takes//preview.mp4`), or the article's `video.mp4`. */ file: string; video: HTMLVideoElement | null; /** The take this file is a preview of, stored on the anchor. */ take?: string; /** Resolve each mark against this project's build schedule. */ resolveProject?: string | null; /** Show the handle's errors here. Off when the handle is shared with a page that shows them itself (the article reader). */ showErrors?: boolean; }) { const [duration, setDuration] = useState(null); const [pending, setPending] = useState(null); const marks = notes.notes.filter(isMomentOn(file)).sort((a, b) => a.anchor.t - b.anchor.t); const mark = useCallback(() => { if (!video) return; setPending(Number(video.currentTime.toFixed(2))); }, [video]); useEffect(() => { if (!video) return; const dur = () => setDuration(Number.isFinite(video.duration) ? video.duration : null); const key = (e: KeyboardEvent) => { if ((e.key === "n" || e.key === "N") && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); e.stopPropagation(); mark(); } }; dur(); video.addEventListener("loadedmetadata", dur); video.addEventListener("durationchange", dur); video.addEventListener("keydown", key); return () => { video.removeEventListener("loadedmetadata", dur); video.removeEventListener("durationchange", dur); video.removeEventListener("keydown", key); }; }, [video, mark]); const seek = (t: number) => { if (!video) return; video.currentTime = t; video.focus(); }; const add = async (t: number, text: string) => { let resolved: (MomentResolved & { entry?: string | null }) | null = null; if (resolveProject) { const q = new URLSearchParams({ project: resolveProject, file, t: String(t) }); if (duration) q.set("duration", String(duration)); resolved = await fetch(`/api/report/moment?${q}`, { cache: "no-store" }) .then((r) => (r.ok ? r.json() : null)) .catch(() => null); } const anchor: MomentAnchor = { kind: "moment", file, t }; if (take) anchor.take = take; if (resolved?.entry) { anchor.entry = resolved.entry; const { entry: _e, ...rest } = resolved as Record; delete rest.schedule; anchor.resolved = rest as MomentResolved; } await notes.write({ op: "add", text, anchor }); }; return (
{/* the tick strip: where the marks are, under the player */} {duration ? (
{marks.map((n) => (
) : null}
{marks.length > 0 && ( {marks.filter((m) => m.status === "open").length} open of {marks.length} )} {showErrors && notes.error && {notes.error}}
{pending !== null && (
{ts(pending)}} onCancel={() => setPending(null)} onAdd={(text) => { const t = pending; setPending(null); void add(t, text); }} />
)} {marks.length > 0 && (
    {marks.map((n) => { const r = n.anchor.resolved; return (
  • {n.anchor.entry && ( {n.anchor.entry} {r?.title ? ` · ${r.title}` : ""} )} {r?.approx && approx} {r?.url && ( source )} } />
  • ); })}
)}
); } /** * A video with its timed notes under it: what the project's final cut, a * take's preview or an article's report video mounts. `notes` defaults to the * page's shared handle for `target` (NotesProvider), else one of its own. */ export function TimedVideo({ target, file, src, take, resolveProject, notes: given, poster, testId, showErrors, className = "aspect-video w-full rounded border border-[var(--color-line)] bg-black", }: { target: NotesTarget; file: string; src: string; take?: string; resolveProject?: string | null; notes?: UseNotes; poster?: string; testId?: string; showErrors?: boolean; className?: string; }) { const own = useSharedNotes(given ? null : target); const notes = given ?? own; const [el, setEl] = useState(null); return (
); }