{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-transcript",
  "title": "Audio Transcript",
  "description": "A timestamped, speaker-labelled transcript synced to a playhead — the spoken line highlights and stays in view until the reader scrolls away, any line seeks to its exact start, and search steps through every hit.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/audio-transcript.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown, ChevronUp, FileText, LocateFixed, Search, TimerOff, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/* --------------------------------------------------------------------------\n * Thresholds of the follow → takeover → return state machine.\n * ----------------------------------------------------------------------- */\n\n/**\n * Every scroll position we write ourselves is recorded in `pinnedTop`. A scroll\n * event reporting anything further than this from that value can only be the\n * reader moving the viewport (wheel, touch, scrollbar, find-in-page, screen\n * reader). 2px absorbs the sub-pixel noise of fractional scrollTop at\n * non-integer zoom / device pixel ratios and nothing else.\n */\nconst TAKEOVER_PX = 2\n/** Auto-follow glide. Short enough to finish between two spoken lines. */\nconst FOLLOW_MS = 260\n\n/** Explicit locale — Intl.*(undefined) renders Eastern Arabic digits for some\n *  visitors while the layout assumes two glyphs per field. */\nconst PAD2 = new Intl.NumberFormat(\"en-US\", { minimumIntegerDigits: 2, useGrouping: false })\nconst PAD3 = new Intl.NumberFormat(\"en-US\", { minimumIntegerDigits: 3, useGrouping: false })\n\n/**\n * `2:03` under an hour, `1:02:03` above it — the hour field is never padded\n * into existence, because a leading `0:` on a 40-minute podcast is noise.\n * `withMillis` keeps the fractional part the data actually carries; rounding\n * 3661.456s to `1:01:01` would silently lie about a contract given in ms.\n */\nfunction formatTimestamp(ms: number, withMillis: boolean) {\n  const safe = Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : 0\n  const totalSeconds = Math.floor(safe / 1000)\n  const h = Math.floor(totalSeconds / 3600)\n  const m = Math.floor((totalSeconds % 3600) / 60)\n  const s = totalSeconds % 60\n  const base = h > 0 ? `${h}:${PAD2.format(m)}:${PAD2.format(s)}` : `${m}:${PAD2.format(s)}`\n  return withMillis ? `${base}.${PAD3.format(safe % 1000)}` : base\n}\n\nfunction prefersReducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  if (!Number.isFinite(value)) return min\n  return value < min ? min : value > max ? max : value\n}\n\n/** One line of the transcript. */\nexport interface TranscriptSegment {\n  /** Stable, unique within the transcript — it keys the row and the scroll map. */\n  id: string\n  text: string\n  /**\n   * Start of this line in **milliseconds**. Leave it out for a transcript with\n   * no timing: that line renders as plain prose instead of a seek target.\n   */\n  startMs?: number\n  /** End in milliseconds. Optional metadata — only shown in the row's tooltip. */\n  endMs?: number\n  /** Matched against `speakers[].id`. Consecutive lines sharing it form one turn. */\n  speakerId?: string\n}\n\n/** Who is talking. Optional: a single-voice transcript needs none of this. */\nexport interface TranscriptSpeaker {\n  id: string\n  /** Printed on every turn — colour is never the only thing telling speakers apart. */\n  name: string\n  /** Falls back to initials on load failure or when omitted. */\n  avatarUrl?: string\n  /** 1…5 → `var(--chart-N)`. Defaults to the speaker's position in the array. */\n  colorIndex?: number\n}\n\nexport interface AudioTranscriptProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** In playback order. The component never sorts. */\n  segments: TranscriptSegment[]\n  speakers?: TranscriptSpeaker[]\n  /**\n   * Controlled playhead in **milliseconds** — `audio.currentTime * 1000`. The\n   * component owns no clock and never plays anything; leave it undefined and\n   * the transcript is a static, still fully searchable, document.\n   */\n  currentTimeMs?: number\n  /**\n   * Fires with the exact `startMs` of the line the reader picked (never a\n   * rounded or re-derived value) plus the segment itself. Apply it to your own\n   * audio element: `audio.currentTime = ms / 1000`.\n   */\n  onSeek?: (ms: number, segment: TranscriptSegment) => void\n  /** Keep the spoken line in view while it plays. The reader can always take over. */\n  autoScroll?: boolean\n  /** Where the followed line sits in the viewport: 0 = top, 1 = bottom. */\n  followAnchor?: number\n  searchable?: boolean\n  showTimestamps?: boolean\n  /**\n   * `auto` prints milliseconds only when some segment starts on a fractional\n   * second, so whole-second transcripts stay clean and precise ones stay exact.\n   */\n  timestampPrecision?: \"auto\" | \"seconds\" | \"milliseconds\"\n  /** Any CSS length. The transcript scrolls inside this box. */\n  height?: number | string\n  /** Accessible name of the scrollable transcript. */\n  label?: string\n  /** Replaces the built-in \"no transcript\" panel. */\n  emptyState?: React.ReactNode\n}\n\ninterface MatchHit {\n  segmentIndex: number\n  start: number\n}\n\n/** Non-overlapping, case-insensitive occurrences of `needle` inside `text`. */\nfunction findRanges(text: string, needle: string) {\n  const lowered = text.toLowerCase()\n  // Some code points change length when lowercased (İ → i̇). When that happens\n  // the offsets no longer address the original string, so that segment falls\n  // back to an exact match rather than slicing the text at wrong indices.\n  const usable = lowered.length === text.length\n  const hay = usable ? lowered : text\n  const pin = usable ? needle.toLowerCase() : needle\n  const out: [number, number][] = []\n  if (pin === \"\") return out\n  let from = 0\n  for (;;) {\n    const at = hay.indexOf(pin, from)\n    if (at === -1) return out\n    out.push([at, at + pin.length])\n    from = at + pin.length\n  }\n}\n\n/** Search highlighting as real elements — the transcript text is never parsed as HTML. */\nfunction HighlightedText({\n  activeRange,\n  ranges,\n  text,\n}: {\n  activeRange?: number\n  ranges?: [number, number][]\n  text: string\n}) {\n  if (!ranges || ranges.length === 0) return <>{text}</>\n  const parts: React.ReactNode[] = []\n  let cursor = 0\n  ranges.forEach(([start, end], i) => {\n    if (start > cursor) parts.push(text.slice(cursor, start))\n    parts.push(\n      <mark\n        className={cn(\n          \"rounded-sm px-0.5\",\n          i === activeRange ? \"bg-primary text-primary-foreground\" : \"bg-primary/20 text-foreground\",\n        )}\n        data-match={i === activeRange ? \"current\" : \"hit\"}\n        key={`m${start}`}\n      >\n        {text.slice(start, end)}\n      </mark>,\n    )\n    cursor = end\n  })\n  if (cursor < text.length) parts.push(text.slice(cursor))\n  return <>{parts}</>\n}\n\nfunction SpeakerAvatar({ name, src, tint }: { name: string; src?: string; tint: string }) {\n  const [failed, setFailed] = React.useState(false)\n  const initials = name\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map(word => word.charAt(0).toUpperCase())\n    .join(\"\")\n\n  return (\n    <span\n      className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-medium text-muted-foreground ring-2\"\n      style={{ \"--tw-ring-color\": tint } as React.CSSProperties}\n    >\n      {src && !failed ? (\n        // eslint-disable-next-line @next/next/no-img-element -- registry 组件保持框架无关,不绑 next/image\n        <img alt=\"\" className=\"size-full object-cover\" onError={() => setFailed(true)} src={src} />\n      ) : (\n        <span aria-hidden=\"true\">{initials || \"?\"}</span>\n      )}\n    </span>\n  )\n}\n\ninterface RowProps {\n  activeRange?: number\n  isActive: boolean\n  onActivate: (segment: TranscriptSegment, viaPointer: boolean) => void\n  ranges?: [number, number][]\n  registerNode: (id: string, node: HTMLElement | null) => void\n  segment: TranscriptSegment\n  speakerHeader?: { name: string; src?: string; tint: string }\n  stamp?: string\n  title?: string\n}\n\n/**\n * Memoised: a 400-line transcript re-renders every row four times a second\n * otherwise, and only two rows actually change when the playhead crosses a\n * boundary. All props are either primitives or references kept stable upstream.\n */\nconst TranscriptRow = React.memo(function TranscriptRow({\n  activeRange,\n  isActive,\n  onActivate,\n  ranges,\n  registerNode,\n  segment,\n  speakerHeader,\n  stamp,\n  title,\n}: RowProps) {\n  const seekable = typeof segment.startMs === \"number\" && Number.isFinite(segment.startMs)\n\n  const body = (\n    <>\n      {stamp !== undefined && (\n        <span\n          className={cn(\n            \"shrink-0 whitespace-nowrap pt-px text-xs tabular-nums\",\n            isActive ? \"text-foreground\" : \"text-muted-foreground\",\n          )}\n        >\n          {stamp}\n        </span>\n      )}\n      <span className={cn(\"min-w-0 flex-1 wrap-anywhere\", isActive && \"font-medium\")}>\n        <HighlightedText activeRange={activeRange} ranges={ranges} text={segment.text} />\n      </span>\n    </>\n  )\n\n  return (\n    <li\n      aria-current={isActive ? \"true\" : undefined}\n      className=\"flex flex-col\"\n      data-active={isActive ? \"true\" : \"false\"}\n      data-segment-id={segment.id}\n      ref={node => registerNode(segment.id, node)}\n    >\n      {speakerHeader && (\n        <span className=\"mt-3 mb-1 flex items-center gap-2 px-2 first:mt-0\" data-speaker-header={speakerHeader.name}>\n          <SpeakerAvatar name={speakerHeader.name} src={speakerHeader.src} tint={speakerHeader.tint} />\n          <span className=\"min-w-0 truncate text-xs font-semibold text-foreground\">{speakerHeader.name}</span>\n        </span>\n      )}\n\n      {seekable ? (\n        <button\n          className={cn(\n            // select-text keeps drag-to-copy working inside a <button>; a\n            // transcript people cannot copy out of is a broken transcript.\n            \"flex w-full cursor-pointer select-text items-start gap-3 border-l-2 px-2 py-1.5 text-left text-sm leading-relaxed\",\n            \"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n            \"motion-reduce:transition-none\",\n            isActive\n              ? \"border-primary bg-primary/10 text-foreground hover:bg-primary/15\"\n              : \"border-transparent text-foreground hover:bg-muted\",\n          )}\n          onClick={event => onActivate(segment, event.detail > 0)}\n          title={title}\n          type=\"button\"\n        >\n          {body}\n        </button>\n      ) : (\n        <p\n          className={cn(\n            \"flex w-full items-start gap-3 border-l-2 px-2 py-1.5 text-sm leading-relaxed\",\n            isActive ? \"border-primary bg-primary/10 font-medium\" : \"border-transparent\",\n          )}\n        >\n          {body}\n        </p>\n      )}\n    </li>\n  )\n})\n\nfunction AudioTranscriptInner(\n  {\n    autoScroll = true,\n    className,\n    currentTimeMs,\n    emptyState,\n    followAnchor = 0.35,\n    height = \"24rem\",\n    label = \"Transcript\",\n    onSeek,\n    searchable = true,\n    segments,\n    showTimestamps = true,\n    speakers,\n    timestampPrecision = \"auto\",\n    ...props\n  }: AudioTranscriptProps,\n  ref: React.Ref<HTMLDivElement>,\n) {\n  const viewportRef = React.useRef<HTMLDivElement | null>(null)\n  const rowNodes = React.useRef(new Map<string, HTMLElement>())\n  /** Mirrors `following` synchronously — two scroll events can share one render closure. */\n  const followRef = React.useRef(true)\n  /** The scrollTop we last wrote ourselves. Anything else moved the viewport. */\n  const pinnedTop = React.useRef(0)\n  const lastTop = React.useRef(0)\n  const frameRef = React.useRef(0)\n  const activeIdRef = React.useRef<string | null>(null)\n  const anchorRef = React.useRef(followAnchor)\n  const seekRef = React.useRef(onSeek)\n\n  const [following, setFollowing] = React.useState(true)\n  const [query, setQuery] = React.useState(\"\")\n  const [hitIndex, setHitIndex] = React.useState(-1)\n\n  const registerNode = React.useCallback((id: string, node: HTMLElement | null) => {\n    if (node) rowNodes.current.set(id, node)\n    else rowNodes.current.delete(id)\n  }, [])\n\n  /* ------------------------------------------------------------ speakers -- */\n\n  const speakerIndex = React.useMemo(() => {\n    const map = new Map<string, { name: string; src?: string; tint: string }>()\n    ;(speakers ?? []).forEach((speaker, i) => {\n      const slot = Math.round(clamp(speaker.colorIndex ?? (i % 5) + 1, 1, 5))\n      map.set(speaker.id, { name: speaker.name, src: speaker.avatarUrl, tint: `var(--chart-${slot})` })\n    })\n    return map\n  }, [speakers])\n\n  const hasSpeakers = React.useMemo(() => segments.some(s => s.speakerId !== undefined), [segments])\n\n  /* -------------------------------------------------------------- timing -- */\n\n  const timedCount = React.useMemo(\n    () => segments.reduce((n, s) => (typeof s.startMs === \"number\" && Number.isFinite(s.startMs) ? n + 1 : n), 0),\n    [segments],\n  )\n\n  const showMillis = React.useMemo(() => {\n    if (timestampPrecision === \"milliseconds\") return true\n    if (timestampPrecision === \"seconds\") return false\n    return segments.some(s => typeof s.startMs === \"number\" && Number.isFinite(s.startMs) && s.startMs % 1000 !== 0)\n  }, [segments, timestampPrecision])\n\n  // Last line whose start is at or before the playhead. A silent gap keeps the\n  // line that was last spoken lit instead of blinking the highlight off, and a\n  // playhead before the first line lights nothing at all.\n  const activeId = React.useMemo(() => {\n    if (currentTimeMs === undefined || !Number.isFinite(currentTimeMs)) return null\n    let found: string | null = null\n    for (const segment of segments) {\n      if (typeof segment.startMs !== \"number\" || !Number.isFinite(segment.startMs)) continue\n      if (segment.startMs <= currentTimeMs) found = segment.id\n    }\n    return found\n  }, [currentTimeMs, segments])\n\n  // Mirrors declared BEFORE the follow pass below, so layout-effect ordering\n  // guarantees the scroll machinery reads this commit's values, not the last one.\n  React.useLayoutEffect(() => {\n    activeIdRef.current = activeId\n    anchorRef.current = clamp(followAnchor, 0, 1)\n    seekRef.current = onSeek\n  })\n\n  /* -------------------------------------------------------------- search -- */\n\n  const needle = query.trim()\n\n  const search = React.useMemo(() => {\n    const ranges = new Map<string, [number, number][]>()\n    const hits: MatchHit[] = []\n    if (needle === \"\") return { hits, ranges }\n    segments.forEach((segment, segmentIndex) => {\n      const found = findRanges(segment.text, needle)\n      if (found.length === 0) return\n      ranges.set(segment.id, found)\n      for (const [start] of found) hits.push({ segmentIndex, start })\n    })\n    return { hits, ranges }\n  }, [needle, segments])\n\n  // Render-phase adjust-state: a new query invalidates the cursor before the\n  // list is drawn, so \"3/12\" can never be shown against a stale result set.\n  const [seenNeedle, setSeenNeedle] = React.useState(needle)\n  if (seenNeedle !== needle) {\n    setSeenNeedle(needle)\n    setHitIndex(-1)\n  }\n\n  const currentHit = hitIndex >= 0 && hitIndex < search.hits.length ? search.hits[hitIndex] : undefined\n  const currentHitSegmentId = currentHit ? segments[currentHit.segmentIndex].id : undefined\n  const currentRangeInSegment = React.useMemo(() => {\n    if (!currentHit || !currentHitSegmentId) return -1\n    const ranges = search.ranges.get(currentHitSegmentId)\n    return ranges ? ranges.findIndex(([start]) => start === currentHit.start) : -1\n  }, [currentHit, currentHitSegmentId, search])\n\n  /* -------------------------------------------------------------- scroll -- */\n\n  const cancelGlide = React.useCallback(() => {\n    if (frameRef.current !== 0) cancelAnimationFrame(frameRef.current)\n    frameRef.current = 0\n  }, [])\n\n  const writeTop = React.useCallback((el: HTMLElement, top: number) => {\n    el.scrollTop = top\n    // Read back: the browser clamps and rounds, and `pinnedTop` has to hold what\n    // the next scroll event will actually report, not what we asked for.\n    pinnedTop.current = el.scrollTop\n    lastTop.current = el.scrollTop\n  }, [])\n\n  /** Where the viewport must sit for `node` to rest on the follow anchor line. */\n  const targetTopFor = React.useCallback((node: HTMLElement) => {\n    const el = viewportRef.current\n    if (!el) return 0\n    const rect = node.getBoundingClientRect()\n    const view = el.getBoundingClientRect()\n    const offsetInContent = el.scrollTop + (rect.top - view.top)\n    const slack = Math.max(0, el.clientHeight - rect.height)\n    return clamp(offsetInContent - slack * anchorRef.current, 0, Math.max(0, el.scrollHeight - el.clientHeight))\n  }, [])\n\n  const glideTo = React.useCallback(\n    (target: number, animate: boolean) => {\n      const el = viewportRef.current\n      if (!el) return\n      cancelGlide()\n      const from = el.scrollTop\n      const distance = target - from\n      if (!animate || prefersReducedMotion() || Math.abs(distance) < 1) {\n        writeTop(el, target)\n        return\n      }\n      const started = performance.now()\n      const step = (now: number) => {\n        const t = Math.min(1, (now - started) / FOLLOW_MS)\n        const eased = 1 - (1 - t) ** 3\n        // Every frame re-records the pin, so a wheel landing mid-glide still\n        // reads as a takeover instead of being mistaken for our own write.\n        writeTop(el, from + distance * eased)\n        frameRef.current = t < 1 ? requestAnimationFrame(step) : 0\n      }\n      frameRef.current = requestAnimationFrame(step)\n    },\n    [cancelGlide, writeTop],\n  )\n\n  const setFollow = React.useCallback((next: boolean) => {\n    if (next) {\n      const el = viewportRef.current\n      // Re-arming has to re-baseline the pin: comparing later scroll events\n      // against a position from before the takeover would fire instantly.\n      if (el) {\n        pinnedTop.current = el.scrollTop\n        lastTop.current = el.scrollTop\n      }\n    }\n    if (followRef.current === next) return\n    followRef.current = next\n    setFollowing(next)\n  }, [])\n\n  const scrollToSegment = React.useCallback(\n    (id: string, animate: boolean) => {\n      const node = rowNodes.current.get(id)\n      if (!node) return\n      glideTo(targetTopFor(node), animate)\n    },\n    [glideTo, targetTopFor],\n  )\n\n  const handleScroll = () => {\n    const el = viewportRef.current\n    if (!el) return\n    const top = el.scrollTop\n    const moved = top - lastTop.current\n\n    if (followRef.current) {\n      // Content shrinking under a pinned position (a shorter `segments` array)\n      // makes the browser clamp scrollTop and fire a scroll event nobody asked\n      // for. Expect the clamped pin, not the raw one — otherwise re-rendering a\n      // filtered transcript silently switches auto-follow off.\n      const expected = Math.min(pinnedTop.current, Math.max(0, el.scrollHeight - el.clientHeight))\n      // Deliberately no \"absorb small drifts\" branch beyond that: re-pinning to\n      // whatever the last event reported would let a slow trackpad glide walk\n      // the viewport anywhere, two pixels at a time, without ever counting as a\n      // takeover. `expected` can only ever move the pin DOWN, and only to the\n      // scroll limit, so it cannot follow a reader.\n      if (Math.abs(top - expected) > TAKEOVER_PX) {\n        cancelGlide()\n        followRef.current = false\n        setFollowing(false)\n      } else {\n        pinnedTop.current = expected\n      }\n    } else {\n      const node = activeIdRef.current ? rowNodes.current.get(activeIdRef.current) : undefined\n      if (node && el.scrollHeight > el.clientHeight) {\n        const rect = node.getBoundingClientRect()\n        const view = el.getBoundingClientRect()\n        // \"Back at the current line\" means seeing it, not landing on a pixel: a\n        // single wheel notch is ~60px and would step clean over a narrow band.\n        // A line taller than the box counts as seen when it fills the box.\n        const inView =\n          (rect.top >= view.top - 1 && rect.bottom <= view.bottom + 1) ||\n          (rect.top <= view.top && rect.bottom >= view.bottom)\n        const offset = top - targetTopFor(node)\n        // Returning also requires motion TOWARDS the anchor. Without that test a\n        // 3px nudge away from it counts as \"back at the line\" and instantly\n        // re-arms auto-follow, which reads as \"the transcript fought me\".\n        const approaching = Math.abs(offset) <= 1 || moved * offset < 0\n        if (inView && approaching) {\n          // Re-arming deliberately does NOT scroll. Snapping the line onto the\n          // anchor the moment following resumes is the yank this whole machine\n          // exists to avoid; the next spoken line glides from wherever we are.\n          followRef.current = true\n          setFollowing(true)\n          pinnedTop.current = top\n        }\n      }\n    }\n    lastTop.current = top\n  }\n\n  /** Auto-follow runs on line changes only — the playhead ticks far more often. */\n  React.useLayoutEffect(() => {\n    if (!autoScroll || !followRef.current || activeId === null) return\n    scrollToSegment(activeId, true)\n  }, [activeId, autoScroll, scrollToSegment])\n\n  // A resized viewport moves the anchor line; a mount fires this too, which is\n  // what lands a transcript opened mid-playback on the right line immediately.\n  React.useEffect(() => {\n    const el = viewportRef.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(() => {\n      if (!autoScroll || !followRef.current) return\n      const id = activeIdRef.current\n      if (id) scrollToSegment(id, false)\n    })\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [autoScroll, scrollToSegment])\n\n  /**\n   * Wheel and touch are treated as takeover the moment they arrive, before the\n   * scroll they cause is even applied: during a glide our own writes land every\n   * frame, so a reader's wheel could otherwise be overwritten and its scroll\n   * event would report OUR position. Guarded by \"can this actually scroll\" —\n   * a wheel at the end stop (overscroll-contain keeps it here) moves nothing\n   * and must not silently switch following off.\n   */\n  React.useEffect(() => {\n    const el = viewportRef.current\n    if (!el) return\n    const scrollable = () => el.scrollHeight - el.clientHeight > 1\n    const onWheel = (event: WheelEvent) => {\n      if (!followRef.current || event.deltaY === 0 || !scrollable()) return\n      const room = event.deltaY < 0 ? el.scrollTop > 0.5 : el.scrollTop < el.scrollHeight - el.clientHeight - 0.5\n      if (!room) return\n      cancelGlide()\n      followRef.current = false\n      setFollowing(false)\n    }\n    const onTouch = () => {\n      if (!followRef.current || !scrollable()) return\n      cancelGlide()\n      followRef.current = false\n      setFollowing(false)\n    }\n    el.addEventListener(\"wheel\", onWheel, { passive: true })\n    el.addEventListener(\"touchmove\", onTouch, { passive: true })\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel)\n      el.removeEventListener(\"touchmove\", onTouch)\n    }\n  }, [cancelGlide])\n\n  React.useEffect(() => () => cancelGlide(), [cancelGlide])\n\n  /* ------------------------------------------------------------- actions -- */\n\n  const activateSegment = React.useCallback(\n    (segment: TranscriptSegment, viaPointer: boolean) => {\n      // A pointer click that ends a drag-selection is the reader copying text,\n      // not asking to seek. Keyboard activation (detail 0) is never suppressed.\n      if (viaPointer) {\n        const selection = typeof window === \"undefined\" ? null : window.getSelection()\n        if (selection && !selection.isCollapsed && selection.toString().trim() !== \"\") return\n      }\n      if (typeof segment.startMs !== \"number\" || !Number.isFinite(segment.startMs)) return\n      setFollow(true)\n      seekRef.current?.(segment.startMs, segment)\n    },\n    [setFollow],\n  )\n\n  const goToHit = React.useCallback(\n    (delta: number) => {\n      if (search.hits.length === 0) return\n      const next = (((hitIndex < 0 ? (delta > 0 ? -1 : 0) : hitIndex) + delta) % search.hits.length + search.hits.length) %\n        search.hits.length\n      setHitIndex(next)\n      const segment = segments[search.hits[next].segmentIndex]\n      const seekable = typeof segment.startMs === \"number\" && Number.isFinite(segment.startMs)\n      if (seekable && seekRef.current) {\n        // Seeking makes the hit the active line, so auto-follow lands on exactly\n        // the position we are about to scroll to — the two never fight.\n        setFollow(true)\n        seekRef.current(segment.startMs as number, segment)\n      } else {\n        // Nothing will move the viewport for us; take over so the jump sticks.\n        setFollow(false)\n      }\n      scrollToSegment(segment.id, true)\n    },\n    [hitIndex, search.hits, scrollToSegment, segments, setFollow],\n  )\n\n  const followCurrent = () => {\n    setFollow(true)\n    if (activeIdRef.current) scrollToSegment(activeIdRef.current, true)\n  }\n\n  /* -------------------------------------------------------------- render -- */\n\n  const rows: React.ReactNode[] = []\n  let previousSpeaker: string | undefined\n  for (let i = 0; i < segments.length; i += 1) {\n    const segment = segments[i]\n    const startsTurn = hasSpeakers && (i === 0 || segment.speakerId !== previousSpeaker)\n    previousSpeaker = segment.speakerId\n    const speaker = segment.speakerId ? speakerIndex.get(segment.speakerId) : undefined\n    const timed = typeof segment.startMs === \"number\" && Number.isFinite(segment.startMs)\n    const stamp = showTimestamps && timed ? formatTimestamp(segment.startMs as number, showMillis) : undefined\n    const endStamp =\n      timed && typeof segment.endMs === \"number\" && Number.isFinite(segment.endMs)\n        ? formatTimestamp(segment.endMs, showMillis)\n        : undefined\n\n    rows.push(\n      <TranscriptRow\n        activeRange={segment.id === currentHitSegmentId ? currentRangeInSegment : undefined}\n        isActive={segment.id === activeId}\n        key={segment.id}\n        onActivate={activateSegment}\n        ranges={search.ranges.get(segment.id)}\n        registerNode={registerNode}\n        segment={segment}\n        speakerHeader={\n          startsTurn\n            ? (speaker ?? { name: segment.speakerId ?? \"Speaker\", src: undefined, tint: \"var(--chart-1)\" })\n            : undefined\n        }\n        stamp={stamp}\n        title={\n          timed\n            ? `Jump to ${formatTimestamp(segment.startMs as number, showMillis)}${endStamp ? ` – ${endStamp}` : \"\"}`\n            : undefined\n        }\n      />,\n    )\n  }\n\n  const untimed = segments.length > 0 && timedCount === 0\n  // No lines, no toolbar: a search box that can never match anything is a dead\n  // control, and the empty panel already says what is going on.\n  const showToolbar = segments.length > 0 && (searchable || untimed)\n  const hitTotal = search.hits.length\n\n  return (\n    <div\n      className={cn(\"flex w-full flex-col overflow-hidden rounded-xl border bg-card\", className)}\n      data-following={following ? \"true\" : \"false\"}\n      ref={ref}\n      {...props}\n    >\n      {showToolbar && (\n        <div className=\"flex flex-col gap-2 border-b px-3 py-2\">\n          {searchable && (\n            <div className=\"flex items-center gap-2\">\n              <div className=\"flex min-w-0 flex-1 items-center gap-2 rounded-md border bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-ring\">\n                <Search aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" />\n                <input\n                  aria-label=\"Search transcript\"\n                  className=\"min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none\"\n                  onChange={event => setQuery(event.target.value)}\n                  onKeyDown={event => {\n                    if (event.key !== \"Enter\") return\n                    event.preventDefault()\n                    goToHit(event.shiftKey ? -1 : 1)\n                  }}\n                  placeholder=\"Search transcript…\"\n                  type=\"search\"\n                  value={query}\n                />\n                {query !== \"\" && (\n                  <button\n                    aria-label=\"Clear search\"\n                    className=\"shrink-0 cursor-pointer rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                    onClick={() => setQuery(\"\")}\n                    type=\"button\"\n                  >\n                    <X aria-hidden=\"true\" className=\"size-3.5\" />\n                  </button>\n                )}\n              </div>\n\n              <span className=\"shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground\">\n                {/* Counts the CURRENT hit, not the raw cursor: a `segments` swap under a\n                    live query shortens the hit list without touching the cursor, and the\n                    counter must not claim a position that is no longer highlighted. */}\n                {needle === \"\" ? \"—\" : `${currentHit ? hitIndex + 1 : 0}/${hitTotal}`}\n              </span>\n\n              <div className=\"flex shrink-0 items-center gap-1\">\n                <button\n                  aria-label=\"Previous match\"\n                  className=\"cursor-pointer rounded-md border p-1 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50\"\n                  disabled={hitTotal === 0}\n                  onClick={() => goToHit(-1)}\n                  type=\"button\"\n                >\n                  <ChevronUp aria-hidden=\"true\" className=\"size-3.5\" />\n                </button>\n                <button\n                  aria-label=\"Next match\"\n                  className=\"cursor-pointer rounded-md border p-1 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50\"\n                  disabled={hitTotal === 0}\n                  onClick={() => goToHit(1)}\n                  type=\"button\"\n                >\n                  <ChevronDown aria-hidden=\"true\" className=\"size-3.5\" />\n                </button>\n              </div>\n            </div>\n          )}\n\n          {searchable && needle !== \"\" && hitTotal === 0 && (\n            <p className=\"text-xs text-muted-foreground\">\n              No line contains “{needle}”. Every line is still listed below.\n            </p>\n          )}\n\n          {untimed && (\n            <p className=\"flex items-start gap-1.5 text-xs text-muted-foreground\" role=\"note\">\n              <TimerOff aria-hidden=\"true\" className=\"mt-px size-3.5 shrink-0\" />\n              <span className=\"min-w-0 wrap-anywhere\">\n                This transcript has no timestamps — it can’t follow playback, and lines aren’t clickable.\n              </span>\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* The search status is announced; the playhead is not. A live region that\n          re-read the transcript every time the playhead moved would make a\n          screen reader unusable during playback. */}\n      <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        {needle === \"\" ? \"\" : `${hitTotal} ${hitTotal === 1 ? \"match\" : \"matches\"} for ${needle}`}\n      </span>\n\n      <div className=\"relative\">\n        <div\n          aria-label={label}\n          // Focusable on purpose: a transcript with no timestamps has no buttons\n          // at all, and a scroll box a keyboard user cannot reach is a trap.\n          className=\"overflow-y-auto overscroll-contain [overflow-anchor:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\"\n          onScroll={handleScroll}\n          ref={viewportRef}\n          role=\"group\"\n          style={{ height }}\n          tabIndex={0}\n        >\n          {segments.length === 0\n            ? (emptyState ?? (\n                <div className=\"flex h-full flex-col items-center justify-center gap-2 p-6 text-center\">\n                  <FileText aria-hidden=\"true\" className=\"size-8 text-muted-foreground/50\" />\n                  <p className=\"text-sm font-medium\">No transcript yet</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Once this recording is transcribed, every line shows up here.\n                  </p>\n                </div>\n              ))\n            : (\n              <ol className=\"flex flex-col p-2\" role=\"list\">\n                {rows}\n              </ol>\n            )}\n        </div>\n\n        {autoScroll && !following && activeId !== null && segments.length > 0 && (\n          <button\n            className=\"absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-xs font-medium shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            onClick={followCurrent}\n            type=\"button\"\n          >\n            <LocateFixed aria-hidden=\"true\" className=\"size-3.5\" />\n            Follow playback\n          </button>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport const AudioTranscript = React.forwardRef(AudioTranscriptInner)\n\nAudioTranscript.displayName = \"AudioTranscript\"\n\nexport default AudioTranscript\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}