"use client"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowLeftToLineIcon, ArrowRightToLineIcon, CheckIcon, ClipboardIcon, DownloadIcon, ExternalLinkIcon, LinkIcon, MessageSquareIcon, MinusIcon, PictureInPicture2Icon, ScrollTextIcon, SparklesIcon, TerminalIcon, XIcon, } from "lucide-react"; import { ControlButton, toHMS, usePlayer, usePlayerTime, type DigestStatus, } from "./PlayerProvider"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import type { VideoDigest } from "../lib/digests"; import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; import { VirtualRow } from "./VirtualRow"; import { formatTimestamp } from "../lib/vtt"; import { formatDate } from "../lib/format"; import { trackLabels } from "../lib/captionTracks"; type DisplayCue = { start: number; end: number; text: string; author: string | null; body: string; }; export default function TranscriptModal() { const { modalOpen, activeSlug, data, status, modalMode, setModalMode, setViewTrack, chatCues, chatStatus, modalNotice, digest, digestStatus, digestAvailable, clipStart, clipEnd, setDisplayMode, closePlayer, markClipStart, markClipEnd, clearClip, copyDownloadCommand, copyShareUrl, downloadTranscriptFile, copyTranscriptMarkdown, openInPreservetube, seekTo, features, } = usePlayer(); // Off on a site whose site.json sets `transcriptDownloads: false`: the two // per-video export controls (the Download menu and Copy MD) are not rendered // at all. The yt-dlp command stays: it copies a line to the clipboard and // serves no file, so it is not a download. Share stays too. const showDownloads = features.transcriptDownloads; const { currentTime } = usePlayerTime(); const scrollRef = useRef(null); const [copied, setCopied] = useState(false); const copyResetRef = useRef(null); const [shareCopied, setShareCopied] = useState(false); const shareResetRef = useRef(null); const [mdCopied, setMdCopied] = useState(false); const mdResetRef = useRef(null); // Controlled only so the trigger can render its open highlight; dismissal // (outside click, Escape, focus) is the Radix menu's job. const [downloadOpen, setDownloadOpen] = useState(false); // Tracks whether the next scrollToIndex should animate. Smooth on // user-initiated changes (cue click, mode toggle, initial open); instant // on natural playhead drift so 4 Hz progress ticks don't keep retriggering // a smooth-scroll animation. const scrollKindRef = useRef<"smooth" | "auto">("smooth"); const isChat = modalMode === "chat"; const isDigest = modalMode === "digest"; const canDownloadFile = isChat ? (chatCues?.length ?? 0) > 0 : (data?.cues?.length ?? 0) > 0; // Pre-compute author/body split once per cue array so the render hot path // doesn't redo `indexOf`/`slice` on every progress tick. Memo key is the // identity of the underlying cues array. const displayCues: DisplayCue[] = useMemo(() => { // The digest panel renders chapters, not cues, and is NOT virtualized — so // the cue list is empty in that mode and the virtualizer below measures // nothing rather than a list that isn't on screen. const raw = isDigest ? [] : isChat ? (chatCues ?? []) : (data?.cues ?? []); return raw.map((c) => { const sepIdx = isChat ? c.text.indexOf(": ") : -1; const author = sepIdx > 0 ? c.text.slice(0, sepIdx) : null; const body = sepIdx > 0 ? c.text.slice(sepIdx + 2) : c.text; return { start: c.start, end: c.end ?? c.start, text: c.text, author, body, }; }); }, [isDigest, isChat, chatCues, data?.cues]); // Chapters are tens of rows, so they render as a plain list. Their active-row // highlight uses the same binary search as the cue list — and reads the clock // from usePlayerTime(), which is why that context is split out: a 4 Hz tick // must not re-render every usePlayer() consumer. const chapters = digest?.chapters ?? []; const activeChapter = findActiveIndex(chapters, currentTime); const cueStatus: "loading" | "ready" = isChat ? chatStatus === "loading" || chatStatus === "idle" ? "loading" : "ready" : status === "loading" ? "loading" : "ready"; const activeIndex = findActiveIndex(displayCues, currentTime); const rowVirtualizer = useVirtualizer({ count: displayCues.length, getScrollElement: () => scrollRef.current, estimateSize: () => 36, overscan: 12, }); // Active row auto-scroll. Smooth on user-initiated jumps; instant during // playback. We let the virtualizer scroll because it handles offsets // correctly even when the target row is not yet rendered. useEffect(() => { if (!modalOpen) return; if (activeIndex < 0) return; rowVirtualizer.scrollToIndex(activeIndex, { align: "center", behavior: scrollKindRef.current, }); scrollKindRef.current = "auto"; }, [activeIndex, modalOpen, modalMode, rowVirtualizer]); // Lock the page behind the modal. Radix's RemoveScroll (sheets, and the // download menu's modal layer) also writes to — the two never overlap, // because nothing that uses RemoveScroll opens *over* this modal. If that ever // changes, the restore below would hand back a value RemoveScroll had already // replaced. useEffect(() => { if (modalOpen) { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; } }, [modalOpen]); useEffect(() => { return () => { if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current); if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current); }; }, []); // Stable seek callback so memo'd CueRow doesn't invalidate per render. const onSeekRow = useCallback( (start: number) => { scrollKindRef.current = "smooth"; seekTo(start); }, [seekTo], ); // Three-way selector, expressed as two toggles that each fall back to the // transcript. A cycling single button would make "get me back to the // transcript" take a variable number of clicks. const onToggleChat = useCallback(() => { scrollKindRef.current = "smooth"; setModalMode(isChat ? "transcript" : "chat"); }, [isChat, setModalMode]); const onToggleDigest = useCallback(() => { scrollKindRef.current = "smooth"; setModalMode(isDigest ? "transcript" : "digest"); }, [isDigest, setModalMode]); // Seek from a chapter. ALWAYS on `start` — the parser already snapped it to a // real cue boundary — and never by re-parsing `clock`, which is the raw model // output kept for auditing and can be wrong. const onSeekChapter = useCallback( (start: number) => { scrollKindRef.current = "smooth"; seekTo(start); }, [seekTo], ); if (!modalOpen || !activeSlug) return null; const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart; const onCopy = async () => { const ok = await copyDownloadCommand(); if (!ok) return; setCopied(true); if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current); copyResetRef.current = window.setTimeout(() => setCopied(false), 1800); }; const onShare = async () => { const ok = await copyShareUrl(); if (!ok) return; setShareCopied(true); if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current); shareResetRef.current = window.setTimeout(() => setShareCopied(false), 1800); }; const virtualItems = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const iconSize = "size-4"; // The mode strip: the same two toggles as before (each falls back to the // transcript), lifted out of the action bar into their own row so the modes // read as a chooser rather than as two more emoji among thirteen. `title` — // and so the accessible name — is unchanged and still depends on the mode. const modeStrip = (
{/* Only offered where a digest exists. ~0.1% of the corpus is digested, so an always-present control would be a dead end on almost every video. */} {digestAvailable && ( ) : ( ) } label={isDigest ? "Transcript" : "AI chapters"} char={isDigest ? "\u{1F4DC}" : "\u2726"} highlight={isDigest} /> )} ) : ( ) } label={isChat ? "Transcript" : "Live chat"} char={isChat ? "\u{1F4DC}" : "\u{1F4AC}"} highlight={isChat} />
); return (
); } // A hairline between control groups in the action strip. function Divider() { return
; } // The digest panel: chapters as a plain (non-virtualized) list, the topic tags, // a provenance line, and — when the digest was borrowed from another video — a // prominent notice saying so. // // Styling deliberately matches this modal's local hard-coded zinc/white palette // rather than the semantic tokens used elsewhere in the app. This component and // PlayerProvider are the one part of the codebase still on literal colors // (bg-zinc-900/80, ring-white/10, active row bg-blue-950/50); migrating them is // its own change, and half-migrating one panel inside them would just look // broken. function DigestPanel({ digest, status, activeChapter, onSeek, }: { digest: VideoDigest | null; status: DigestStatus; activeChapter: number; onSeek: (start: number) => void; }) { if (status === "loading" || status === "idle") { return

Loading AI digest…

; } if (!digest) { return (

No AI digest for this video.

); } const prov = digest.provenance.chapters ?? digest.provenance.tags; const borrowed = digest.derivedFrom; return (
{borrowed && (

Borrowed from a duplicate upload

These chapters were generated for{" "} {borrowed.slug}, a near-identical copy of this video, and copied here. Timings were measured to differ by {formatOffset(borrowed.offsetSeconds)}, so they should line up — but the titles describe that upload, not this one.

)} {digest.chapters.length > 0 ? (
    {digest.chapters.map((c, i) => (
  1. ))}
) : (

No chapters in this digest.

)} {digest.tags.length > 0 && (

Topics

    {digest.tags.map((t) => (
  • {t.tag}
  • ))}
)} {/* Always say where this came from. A derived layer that doesn't announce itself as machine-generated is the one that misleads. */}

{prov ? ( <> Chapters and topics generated by{" "} {prov.model} {prov.generatedAt ? ` on ${prov.generatedAt.slice(0, 10)}` : ""} . AI-generated and not reviewed unless marked{" "} edited. ) : ( // No machine provenance at all: this digest is entirely hand-written // (an overrides file with no generated counterpart). Claiming // "AI-generated" here would be the same dishonesty in reverse. "Written by hand." )}

); } // The measured cue-timing offset between a borrowed digest's source video and // this one. Sharing only happens at near-zero offset, so this is normally well // under a second — say so precisely rather than rounding it away to "0s". function formatOffset(seconds: number): string { const s = Math.abs(seconds); if (s < 1) return `under a second`; return `${s.toFixed(1)}s`; } const CueRow = memo(function CueRow({ cue, isActive, onSeek, measureRef, virtualIndex, offset, }: { cue: DisplayCue; isActive: boolean; onSeek: (start: number) => void; measureRef: (el: Element | null) => void; virtualIndex: number; offset: number; }) { return ( ); }); function ClipReadout({ clipStart, clipEnd, onClear, }: { clipStart: number | null; clipEnd: number | null; onClear: () => void; }) { if (clipStart === null && clipEnd === null) { return ( no clip set ); } return ( {clipStart !== null ? toHMS(clipStart) : "--:--:--"} → {clipEnd !== null ? toHMS(clipEnd) : "--:--:--"} ); } function findActiveIndex( cues: { start: number }[], t: number, ): number { if (!cues.length) return -1; let lo = 0; let hi = cues.length - 1; let found = -1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (cues[mid].start <= t) { found = mid; lo = mid + 1; } else { hi = mid - 1; } } return found; } // "Track: original audio captions ▾" — small and quiet, beside the mode strip. // The first track is the primary (the default); its label says so in the menu. function TrackSwitcher({ tracks, track, onChange, }: { tracks: string[]; track: string; onChange: (track: string) => void; }) { return ( ); }