{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-player",
  "title": "Audio Player",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/slider.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/audio-player.tsx",
      "content": "// Ballmac UI: Audio Player. https://ui.ballmac.com/components/audio-player\n\"use client\"\n\nimport * as React from \"react\"\nimport { Pause, Play, RotateCcw, RotateCw } from \"lucide-react\"\n\nimport { Slider } from \"@/components/ballmac/slider\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype AudioChapter = {\n  /** Start time in seconds. */\n  start: number\n  /** Chapter title. */\n  title: string\n}\n\ntype AudioPlayerProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"onTimeUpdate\"> & {\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 msg = useMessages()\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 = \"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 type=\"button\" onClick={() => seek(time - skip.back)} className={cn(btn, \"relative size-10\")} aria-label={msg(\"audio-player.backSeconds\", \"Back {back} seconds\", { back: skip.back })}>\n        <RotateCcw className=\"size-[22px]\" aria-hidden=\"true\" />\n        <span className=\"absolute text-[9px] font-bold tabular-nums\" aria-hidden=\"true\">{skip.back}</span>\n      </button>\n      <button type=\"button\" onClick={toggle} className={cn(btn, \"bg-primary text-primary-foreground hover:bg-primary/90 size-12\")} aria-label={playing ? msg(\"audio-player.pause\", \"Pause\") : msg(\"audio-player.play\", \"Play\")}>\n        {playing ? <Pause className=\"size-5 fill-current\" aria-hidden=\"true\" /> : <Play className=\"size-5 translate-x-px fill-current\" aria-hidden=\"true\" />}\n      </button>\n      <button type=\"button\" onClick={() => seek(time + skip.forward)} className={cn(btn, \"relative size-10\")} aria-label={msg(\"audio-player.forwardSeconds\", \"Forward {forward} seconds\", { forward: skip.forward })}>\n        <RotateCw className=\"size-[22px]\" aria-hidden=\"true\" />\n        <span className=\"absolute text-[9px] font-bold tabular-nums\" aria-hidden=\"true\">{skip.forward}</span>\n      </button>\n    </div>\n  )\n\n  const scrubber = (\n    <div className=\"min-w-0 flex-1\">\n      <div className=\"relative\">\n        <Slider\n          aria-label={msg(\"audio-player.seek\", \"Seek\")}\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          formatValue={(v) => label.replace(formatTime(time), formatTime(v))}\n        />\n        {chapters.length > 1 && total > 0 && (\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-1/2 h-0\">\n            {chapters.slice(1).map((c) => <span key={c.start} className=\"bg-background absolute -top-1.5 h-3 w-0.5 rounded\" style={{ left: `${(c.start / total) * 100}%` }} />)}\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 type=\"button\" onClick={() => setRateIndex((i) => (i + 1) % rates.length)} className={cn(btn, \"h-9 min-w-12 px-2.5 text-sm font-semibold tabular-nums\")} aria-label={msg(\"audio-player.playbackSpeedChangeSpeed\", \"Playback speed {rate}×. Change speed\", { rate })}>\n      {rate}×\n    </button>\n  )\n\n  return (\n    <div data-slot=\"audio-player\" data-playing={playing || undefined} className={cn(\"bg-card text-card-foreground rounded-2xl border\", variant === \"compact\" ? \"p-3\" : \"p-4 sm:p-5\", className)} role=\"group\" aria-label={msg(\"audio-player.audioPlayer\", \"Audio player: {title}\", { title })} {...props}>\n      {src && <audio ref={audio} src={src} preload=\"metadata\" onLoadedMetadata={(e) => setLength(e.currentTarget.duration)} onTimeUpdate={(e) => setInner(e.currentTarget.currentTime)} onEnded={() => setPlaying(false)} />}\n      {variant === \"compact\" ? (\n        <div className=\"flex items-center gap-3\">\n          {controls}\n          <div className=\"min-w-0 flex-1\"><p className=\"truncate text-sm font-semibold\">{title}</p>{scrubber}</div>\n          {speed}\n        </div>\n      ) : (\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"flex items-center gap-4\">\n            {artwork && <div className=\"size-16 shrink-0 overflow-hidden rounded-xl sm:size-20\">{artwork}</div>}\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-base font-semibold\">{title}</p>\n              {subtitle && <p className=\"text-muted-foreground truncate text-sm\">{subtitle}</p>}\n              {chapter && <p className=\"text-muted-foreground mt-1 truncate text-xs\" aria-live=\"polite\">{msg(\"audio-player.chapter\", \"Chapter:\")} <span className=\"text-foreground font-medium\">{chapter.title}</span></p>}\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",
      "target": "@components/ballmac/audio-player.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "audio",
      "player",
      "podcast",
      "music",
      "media",
      "chapters",
      "speed"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Pass src (omit it for a silent demo), title, duration and optional chapters=[{ start, title }]. Control the position with time and onTimeChange to sync a transcript. variant='compact' is a one-row mini player.",
      "whenToUse": [
        "Podcast episodes, audio articles, voice notes and music previews",
        "Syncing a transcript or chapter list to playback"
      ],
      "whenNotToUse": [
        "Video (use the native video element or video-dialog)"
      ],
      "composesWith": [
        "slider",
        "segmented-control",
        "scroll-area"
      ],
      "a11y": [
        {
          "keys": "Space / Enter on Play",
          "action": "Plays or pauses; the name switches between Play and Pause"
        },
        {
          "keys": "Arrow keys on the scrubber",
          "action": "Seeks one second; Page Up / Down seeks more; the value reads as 'm:ss of m:ss'"
        },
        {
          "keys": "Skip buttons",
          "action": "Jump back or forward by the configured seconds"
        }
      ],
      "customization": [
        "rates: the speeds the speed button cycles through",
        "skip: { back, forward } seconds",
        "artwork: any node",
        "variant: default or compact"
      ]
    },
    "examples": [
      "audio-player-demo",
      "audio-player-compact"
    ],
    "url": "https://ui.ballmac.com/components/audio-player"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}