{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-player",
  "title": "Audio Player",
  "description": "A podcast-style audio player with play and pause, skip back and forward, a scrubber with chapter ticks, the current chapter and a speed control. Works with a real file or as a silent demo.",
  "dependencies": [
    "lucide-react",
    "@radix-ui/react-slider"
  ],
  "files": [
    {
      "path": "registry/tealmac/audio-player.tsx",
      "content": "// Adapted from Ballmac UI (https://github.com/vamsiy78/ballmac-ui), MIT License, Copyright (c) 2026 Ballmac.\n\"use client\"\n\nimport * as React from \"react\"\nimport * as SliderPrimitive from \"@radix-ui/react-slider\"\nimport { Pause, Play, RotateCcw, RotateCw } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype AudioChapter = {\n  /** Start time in seconds. */\n  start: number\n  /** Chapter title. */\n  title: string\n}\n\ntype AudioPlayerProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"title\" | \"onTimeUpdate\"\n> & {\n  /** Audio file URL. Leave it out for a silent demo that advances a clock, so you can design the page before the audio exists. */\n  src?: string\n  /** Episode or track title. */\n  title: string\n  /** Show or artist, shown under the title. */\n  subtitle?: string\n  /** Length in seconds. Used before the file's metadata loads, and as the length of a silent demo. */\n  duration?: number\n  /** Chapters. Drawn as ticks on the scrubber, and the current one is named under the title. */\n  chapters?: AudioChapter[]\n  /** Artwork shown on the left. */\n  artwork?: React.ReactNode\n  /** Playback speeds the speed button cycles through. */\n  rates?: number[]\n  /** Seconds the back and forward buttons skip. */\n  skip?: { back: number; forward: number }\n  /** Controlled position in seconds. */\n  time?: number\n  /** Called when the position changes (playback, seeking or skipping). */\n  onTimeChange?: (seconds: number) => void\n  /** Called when play or pause is chosen. */\n  onPlayingChange?: (playing: boolean) => void\n  /** \"compact\" is a single row, for a sticky mini player. */\n  variant?: \"default\" | \"compact\"\n}\n\n/** m:ss, or h:mm:ss for long audio. */\nfunction formatTime(s: number) {\n  const t = Math.max(0, Math.floor(s))\n  const h = Math.floor(t / 3600)\n  const m = Math.floor((t % 3600) / 60)\n  const sec = String(t % 60).padStart(2, \"0\")\n  return h > 0 ? `${h}:${String(m).padStart(2, \"0\")}:${sec}` : `${m}:${sec}`\n}\n\n/** An audio player with play and pause, skip back and forward, a scrubber, chapters and a speed control. Works with a real file or as a silent demo. */\nfunction AudioPlayer({\n  src,\n  title,\n  subtitle,\n  duration: durationProp = 0,\n  chapters = [],\n  artwork,\n  rates = [1, 1.25, 1.5, 2, 0.75],\n  skip = { back: 15, forward: 30 },\n  time: timeProp,\n  onTimeChange,\n  onPlayingChange,\n  variant = \"default\",\n  className,\n  ...props\n}: AudioPlayerProps) {\n  const audio = React.useRef<HTMLAudioElement>(null)\n  const [inner, setInner] = React.useState(0)\n  const [length, setLength] = React.useState(durationProp)\n  const [playing, setPlaying] = React.useState(false)\n  const [rateIndex, setRateIndex] = React.useState(0)\n  const time = timeProp ?? inner\n  const rate = rates[rateIndex] ?? 1\n  const total = length || durationProp\n\n  // Latest values for the timer and the change callback, kept in refs so they never restart the interval.\n  const timeRef = React.useRef(time)\n  const onTimeRef = React.useRef(onTimeChange)\n  React.useEffect(() => {\n    timeRef.current = time\n    onTimeRef.current = onTimeChange\n  })\n\n  // Report every change of position once, whether it came from playback, the scrubber or a skip.\n  const first = React.useRef(true)\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false\n      return\n    }\n    onTimeRef.current?.(inner)\n  }, [inner])\n\n  // A silent demo (no src) advances its own clock.\n  React.useEffect(() => {\n    if (src || !playing) return\n    const id = setInterval(() => {\n      const next = Math.min(total, timeRef.current + 0.25 * rate)\n      timeRef.current = next\n      setInner(next)\n      if (next >= total) {\n        setPlaying(false)\n        onPlayingChange?.(false)\n      }\n    }, 250)\n    return () => clearInterval(id)\n  }, [src, playing, rate, total, onPlayingChange])\n\n  // Keep a real element in step with what the controls say.\n  React.useEffect(() => {\n    const el = audio.current\n    if (!el) return\n    el.playbackRate = rate\n  }, [rate])\n  React.useEffect(() => {\n    const el = audio.current\n    if (!el || timeProp === undefined) return\n    if (Math.abs(el.currentTime - timeProp) > 1) el.currentTime = timeProp\n  }, [timeProp])\n\n  function toggle() {\n    const next = !playing\n    const el = audio.current\n    if (el) {\n      if (next) el.play().catch(() => setPlaying(false))\n      else el.pause()\n    } else if (next && total && time >= total) {\n      setInner(0)\n    }\n    setPlaying(next)\n    onPlayingChange?.(next)\n  }\n  function seek(to: number) {\n    const clamped = Math.min(Math.max(0, to), total || to)\n    setInner(clamped)\n    if (audio.current) audio.current.currentTime = clamped\n  }\n\n  const chapter = [...chapters].reverse().find((c) => time >= c.start)\n  const label = `${formatTime(time)} of ${formatTime(total)}`\n  const btn =\n    \"text-foreground hover:bg-accent focus-visible:ring-ring/50 inline-flex shrink-0 items-center justify-center rounded-full outline-none transition-colors focus-visible:ring-[3px]\"\n\n  const controls = (\n    <div className=\"flex items-center gap-1.5\">\n      <button\n        type=\"button\"\n        onClick={() => seek(time - skip.back)}\n        className={cn(btn, \"relative size-10\")}\n        aria-label={`Back ${skip.back} seconds`}\n      >\n        <RotateCcw className=\"size-[22px]\" aria-hidden=\"true\" />\n        <span\n          className=\"absolute text-[9px] font-bold tabular-nums\"\n          aria-hidden=\"true\"\n        >\n          {skip.back}\n        </span>\n      </button>\n      <button\n        type=\"button\"\n        onClick={toggle}\n        className={cn(\n          btn,\n          \"bg-primary text-primary-foreground hover:bg-primary/90 size-12\"\n        )}\n        aria-label={playing ? \"Pause\" : \"Play\"}\n      >\n        {playing ? (\n          <Pause className=\"size-5 fill-current\" aria-hidden=\"true\" />\n        ) : (\n          <Play\n            className=\"size-5 translate-x-px fill-current\"\n            aria-hidden=\"true\"\n          />\n        )}\n      </button>\n      <button\n        type=\"button\"\n        onClick={() => seek(time + skip.forward)}\n        className={cn(btn, \"relative size-10\")}\n        aria-label={`Forward ${skip.forward} seconds`}\n      >\n        <RotateCw className=\"size-[22px]\" aria-hidden=\"true\" />\n        <span\n          className=\"absolute text-[9px] font-bold tabular-nums\"\n          aria-hidden=\"true\"\n        >\n          {skip.forward}\n        </span>\n      </button>\n    </div>\n  )\n\n  const scrubber = (\n    <div className=\"min-w-0 flex-1\">\n      <div className=\"relative\">\n        <SliderPrimitive.Root\n          data-slot=\"audio-player-scrubber\"\n          min={0}\n          max={Math.max(total, 1)}\n          step={1}\n          value={[Math.min(time, Math.max(total, 1))]}\n          onValueChange={(v) => seek(v[0] ?? 0)}\n          className=\"relative flex h-5 w-full touch-none items-center select-none\"\n        >\n          <SliderPrimitive.Track className=\"bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full\">\n            <SliderPrimitive.Range className=\"bg-primary absolute h-full\" />\n          </SliderPrimitive.Track>\n          <SliderPrimitive.Thumb\n            aria-label=\"Seek\"\n            aria-valuetext={label}\n            className=\"border-primary bg-background ring-ring/50 block size-4 rounded-full border-2 shadow-sm transition-[box-shadow] outline-none hover:ring-4 focus-visible:ring-4\"\n          />\n        </SliderPrimitive.Root>\n        {chapters.length > 1 && total > 0 && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-x-0 top-1/2 h-0\"\n          >\n            {chapters.slice(1).map((c) => (\n              <span\n                key={c.start}\n                className=\"bg-background absolute -top-1.5 h-3 w-0.5 rounded\"\n                style={{ left: `${(c.start / total) * 100}%` }}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n      <div className=\"text-muted-foreground mt-1.5 flex justify-between text-xs tabular-nums\">\n        <span>{formatTime(time)}</span>\n        <span>−{formatTime(Math.max(0, total - time))}</span>\n      </div>\n    </div>\n  )\n\n  const speed = (\n    <button\n      type=\"button\"\n      onClick={() => setRateIndex((i) => (i + 1) % rates.length)}\n      className={cn(\n        btn,\n        \"h-9 min-w-12 px-2.5 text-sm font-semibold tabular-nums\"\n      )}\n      aria-label={`Playback speed ${rate}×. Change speed`}\n    >\n      {rate}×\n    </button>\n  )\n\n  return (\n    <div\n      data-slot=\"audio-player\"\n      data-playing={playing || undefined}\n      className={cn(\n        \"bg-card text-card-foreground rounded-2xl border\",\n        variant === \"compact\" ? \"p-3\" : \"p-4 sm:p-5\",\n        className\n      )}\n      role=\"group\"\n      aria-label={`Audio player: ${title}`}\n      {...props}\n    >\n      {src && (\n        // eslint-disable-next-line jsx-a11y/media-has-caption -- spoken-word audio; provide a transcript alongside the player\n        <audio\n          ref={audio}\n          src={src}\n          preload=\"metadata\"\n          onLoadedMetadata={(e) => setLength(e.currentTarget.duration)}\n          onTimeUpdate={(e) => setInner(e.currentTarget.currentTime)}\n          onEnded={() => setPlaying(false)}\n        />\n      )}\n      {variant === \"compact\" ? (\n        <div className=\"flex items-center gap-3\">\n          {controls}\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"truncate text-sm font-semibold\">{title}</p>\n            {scrubber}\n          </div>\n          {speed}\n        </div>\n      ) : (\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"flex items-center gap-4\">\n            {artwork && (\n              <div className=\"size-16 shrink-0 overflow-hidden rounded-xl sm:size-20\">\n                {artwork}\n              </div>\n            )}\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-base font-semibold\">{title}</p>\n              {subtitle && (\n                <p className=\"text-muted-foreground truncate text-sm\">\n                  {subtitle}\n                </p>\n              )}\n              {chapter && (\n                <p\n                  className=\"text-muted-foreground mt-1 truncate text-xs\"\n                  aria-live=\"polite\"\n                >\n                  {\"Chapter:\"}{\" \"}\n                  <span className=\"text-foreground font-medium\">\n                    {chapter.title}\n                  </span>\n                </p>\n              )}\n            </div>\n            {speed}\n          </div>\n          {scrubber}\n          <div className=\"flex justify-center\">{controls}</div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport { AudioPlayer, formatTime, type AudioChapter, type AudioPlayerProps }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}