{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-player",
  "title": "Audio Player",
  "description": "A recording with its waveform, its position and its transcript, every line seekable.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.tinkererslabs.com/r/render-surface.json"
  ],
  "files": [
    {
      "path": "registry/default/audio-player/audio-player.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Pause, Play } from \"lucide-react\"\n\nimport {\n  RenderSurface,\n  useThemeColors,\n  type SurfaceColor,\n} from \"@/registry/default/render-surface/render-surface\"\nimport { cn } from \"@/lib/utils\"\n\nexport type TranscriptLine = {\n  /** Seconds from the start of the audio. */\n  start: number\n  /** Seconds. Only needed when lines do not run back to back. */\n  end?: number\n  text: string\n  speaker?: string\n}\n\nexport type AudioPlayerProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onError\"\n> & {\n  src: string | Blob\n  /** Draw the audio behind the scrubber. */\n  waveform?: boolean\n  /** Precomputed amplitudes, 0 to 1. Supplying these skips decoding entirely. */\n  peaks?: readonly number[]\n  transcript?: readonly TranscriptLine[]\n  /** Playback rates the button cycles through. */\n  rates?: readonly number[]\n  /** Above this many bytes the audio plays without a drawn waveform. */\n  maxDecodeBytes?: number\n  /** A theme token for the played portion. */\n  color?: string\n  label?: string\n}\n\nconst BARS = 96\n\nfunction formatTime(seconds: number) {\n  if (!Number.isFinite(seconds) || seconds < 0) return \"0:00\"\n\n  const whole = Math.floor(seconds)\n  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, \"0\")}`\n}\n\nfunction peaksOf(buffer: AudioBuffer, bars: number) {\n  const channel = buffer.getChannelData(0)\n  const per = Math.floor(channel.length / bars) || 1\n  const found: number[] = []\n  let loudest = 0\n\n  for (let bar = 0; bar < bars; bar += 1) {\n    let peak = 0\n\n    for (\n      let at = bar * per;\n      at < (bar + 1) * per && at < channel.length;\n      at++\n    ) {\n      peak = Math.max(peak, Math.abs(channel[at]!))\n    }\n\n    loudest = Math.max(loudest, peak)\n    found.push(peak)\n  }\n\n  return loudest > 0 ? found.map((peak) => peak / loudest) : found\n}\n\nfunction lineAt(lines: readonly TranscriptLine[], time: number) {\n  for (let at = lines.length - 1; at >= 0; at -= 1) {\n    const line = lines[at]!\n    if (time < line.start) continue\n    return line.end !== undefined && time >= line.end ? -1 : at\n  }\n\n  return -1\n}\n\n/**\n * Plays a recording and shows what is in it: the shape of the audio, where you\n * are inside it, and the words if you have them.\n *\n * The waveform is drawn, so the control that seeks is a real range input laid\n * over it. Dragging a picture is not something a keyboard or a screen reader\n * can do, and the picture is the part that is optional.\n */\nexport function AudioPlayer({\n  src,\n  waveform = false,\n  peaks,\n  transcript,\n  rates = [1, 1.5, 2],\n  maxDecodeBytes = 40_000_000,\n  color = \"--primary\",\n  label = \"Recording\",\n  className,\n  ...rootProps\n}: AudioPlayerProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const audioRef = React.useRef<HTMLAudioElement>(null)\n  const activeRef = React.useRef<HTMLLIElement>(null)\n\n  const [playing, setPlaying] = React.useState(false)\n  const [time, setTime] = React.useState(0)\n  const [duration, setDuration] = React.useState(0)\n  const [rate, setRate] = React.useState(rates[0] ?? 1)\n  const [decoded, setDecoded] = React.useState<readonly number[] | null>(null)\n  const drawn = peaks ?? decoded\n\n  const tokens = React.useMemo(\n    () => (color.startsWith(\"--\") ? [color, \"--muted-foreground\"] : []),\n    [color]\n  )\n  const resolved = useThemeColors(rootRef, tokens)\n  const played = color.startsWith(\"--\") ? resolved[color] : undefined\n  const rest = resolved[\"--muted-foreground\"]\n\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const sync = () => setReduced(query.matches)\n\n    sync()\n    query.addEventListener(\"change\", sync)\n    return () => query.removeEventListener(\"change\", sync)\n  }, [])\n\n  const url = React.useMemo(\n    () => (typeof src === \"string\" ? src : URL.createObjectURL(src)),\n    [src]\n  )\n\n  React.useEffect(() => {\n    if (typeof src === \"string\") return\n    return () => URL.revokeObjectURL(url)\n  }, [src, url])\n\n  React.useEffect(() => {\n    if (peaks || !waveform) return\n\n    let cancelled = false\n    const controller = new AbortController()\n\n    const load = async () => {\n      const bytes =\n        typeof src === \"string\"\n          ? await (\n              await fetch(url, { signal: controller.signal })\n            ).arrayBuffer()\n          : await src.arrayBuffer()\n\n      // Decoding holds the whole file uncompressed. An hour of speech is\n      // hundreds of megabytes, so past the ceiling the audio still plays and\n      // only the picture is given up.\n      if (cancelled || bytes.byteLength > maxDecodeBytes) return\n\n      const context = new AudioContext()\n      try {\n        const buffer = await context.decodeAudioData(bytes)\n        if (!cancelled) setDecoded(peaksOf(buffer, BARS))\n      } finally {\n        void context.close().catch(() => {})\n      }\n    }\n\n    void load().catch(() => {})\n\n    return () => {\n      cancelled = true\n      controller.abort()\n    }\n  }, [maxDecodeBytes, peaks, src, url, waveform])\n\n  React.useEffect(() => {\n    const element = audioRef.current\n    if (element) element.playbackRate = rate\n  }, [rate])\n\n  const readDuration = React.useCallback(() => {\n    const element = audioRef.current\n    if (!element) return\n\n    // A file from MediaRecorder carries no duration, and the browser reports\n    // Infinity until it has been asked to look. Seeking past the end makes it\n    // look, and it reports the real length on the next metadata event.\n    if (element.duration === Infinity) {\n      element.currentTime = Number.MAX_SAFE_INTEGER\n      return\n    }\n\n    if (Number.isFinite(element.duration)) {\n      setDuration(element.duration)\n      if (element.currentTime > element.duration) element.currentTime = 0\n    }\n  }, [])\n\n  const seek = React.useCallback((to: number) => {\n    const element = audioRef.current\n    if (!element) return\n\n    element.currentTime = to\n    setTime(to)\n  }, [])\n\n  const active = transcript ? lineAt(transcript, time) : -1\n\n  React.useEffect(() => {\n    if (!playing || active < 0) return\n\n    activeRef.current?.scrollIntoView({\n      block: \"nearest\",\n      behavior: reduced ? \"auto\" : \"smooth\",\n    })\n  }, [active, playing, reduced])\n\n  const setup = React.useCallback(() => null, [])\n\n  const draw = React.useCallback(\n    ({\n      context,\n      size: box,\n    }: {\n      context: CanvasRenderingContext2D\n      size: { width: number; height: number; dpr: number }\n    }) => {\n      if (!played || !rest) return\n\n      context.setTransform(box.dpr, 0, 0, box.dpr, 0, 0)\n      context.clearRect(0, 0, box.width, box.height)\n\n      const bars = drawn ?? []\n      const count = bars.length || BARS\n      const width = box.width / count\n      const middle = box.height / 2\n      const progress = duration > 0 ? time / duration : 0\n\n      for (let bar = 0; bar < count; bar += 1) {\n        // A flat line before the shape is known, rather than an empty box.\n        const peak = bars[bar] ?? 0.02\n        const height = Math.max(2, peak * box.height * 0.86)\n        const [r, g, b] = (\n          (bar + 0.5) / count <= progress ? played : rest\n        ) as SurfaceColor\n\n        context.fillStyle = `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`\n        context.beginPath()\n        context.roundRect(\n          bar * width + width * 0.2,\n          middle - height / 2,\n          Math.max(width * 0.6, 1),\n          height,\n          width * 0.3\n        )\n        context.fill()\n      }\n    },\n    [drawn, duration, played, rest, time]\n  )\n\n  const position = `${formatTime(time)} of ${formatTime(duration)}`\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"audio-player\"\n      data-playing={playing ? \"\" : undefined}\n      className={cn(\n        \"border-border bg-background flex w-full flex-col gap-3 rounded-xl border p-3\",\n        className\n      )}\n      {...rootProps}\n    >\n      <audio\n        ref={audioRef}\n        src={url}\n        preload=\"metadata\"\n        onLoadedMetadata={readDuration}\n        onDurationChange={readDuration}\n        onTimeUpdate={(event) => setTime(event.currentTarget.currentTime)}\n        onPlay={() => setPlaying(true)}\n        onPause={() => setPlaying(false)}\n        onEnded={() => setPlaying(false)}\n      />\n\n      <div className=\"flex items-center gap-3\">\n        <button\n          type=\"button\"\n          aria-label={playing ? `Pause ${label}` : `Play ${label}`}\n          onClick={() => {\n            const element = audioRef.current\n            if (!element) return\n            if (playing) element.pause()\n            else void element.play().catch(() => {})\n          }}\n          className=\"bg-foreground text-background focus-visible:ring-ring inline-flex size-10 shrink-0 items-center justify-center rounded-full transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\"\n        >\n          {playing ? (\n            <Pause aria-hidden=\"true\" size={16} />\n          ) : (\n            <Play aria-hidden=\"true\" size={16} />\n          )}\n        </button>\n\n        <div className=\"focus-within:ring-ring relative min-w-0 flex-1 rounded-md focus-within:ring-2\">\n          {waveform && played !== undefined ? (\n            <RenderSurface<null, \"2d\">\n              setup={setup}\n              draw={draw}\n              paused={!playing}\n              revision={`${drawn?.length ?? 0}:${Math.round((duration > 0 ? time / duration : 0) * 400)}`}\n              className=\"pointer-events-none h-12 w-full\"\n              canvasClassName=\"h-full w-full\"\n            />\n          ) : (\n            <div\n              aria-hidden=\"true\"\n              className=\"bg-muted pointer-events-none h-1.5 w-full overflow-hidden rounded-full\"\n            >\n              <div\n                className=\"bg-primary h-full\"\n                style={{\n                  width: `${duration > 0 ? (time / duration) * 100 : 0}%`,\n                }}\n              />\n            </div>\n          )}\n\n          <input\n            type=\"range\"\n            min={0}\n            max={duration || 0}\n            step={0.01}\n            value={Math.min(time, duration || 0)}\n            disabled={duration === 0}\n            aria-label={`Seek ${label}`}\n            aria-valuetext={position}\n            onChange={(event) => seek(Number(event.target.value))}\n            className=\"absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-default\"\n          />\n        </div>\n\n        <span\n          data-slot=\"audio-player-time\"\n          className=\"text-muted-foreground shrink-0 font-mono text-xs tabular-nums\"\n        >\n          {formatTime(time)}\n          <span className=\"opacity-60\"> / {formatTime(duration)}</span>\n        </span>\n\n        {rates.length > 1 ? (\n          <button\n            type=\"button\"\n            aria-label={`Playback speed, ${rate} times. Change.`}\n            onClick={() =>\n              setRate(rates[(rates.indexOf(rate) + 1) % rates.length] ?? 1)\n            }\n            className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex h-8 shrink-0 items-center rounded-md px-2 font-mono text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\"\n          >\n            {rate}&times;\n          </button>\n        ) : null}\n      </div>\n\n      {transcript?.length ? (\n        <ol\n          data-slot=\"audio-player-transcript\"\n          className=\"border-border max-h-56 overflow-y-auto border-t pt-2 text-sm\"\n        >\n          {transcript.map((line, at) => (\n            <li\n              key={`${line.start}-${at}`}\n              ref={at === active ? activeRef : null}\n            >\n              <button\n                type=\"button\"\n                aria-current={at === active ? \"true\" : undefined}\n                onClick={() => seek(line.start)}\n                className=\"hover:bg-muted/60 focus-visible:ring-ring aria-[current]:text-foreground text-muted-foreground flex w-full items-baseline gap-3 rounded-md px-2 py-1.5 text-start transition-colors focus-visible:ring-2 focus-visible:outline-none aria-[current]:font-medium motion-reduce:transition-none\"\n              >\n                <span className=\"shrink-0 font-mono text-xs tabular-nums opacity-70\">\n                  {formatTime(line.start)}\n                </span>\n                <span className=\"min-w-0\">\n                  {line.speaker ? (\n                    <span className=\"text-foreground me-1.5 font-semibold\">\n                      {line.speaker}\n                    </span>\n                  ) : null}\n                  {line.text}\n                </span>\n              </button>\n            </li>\n          ))}\n        </ol>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "agent",
    "media",
    "audio",
    "canvas",
    "player"
  ],
  "type": "registry:ui"
}