{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-player",
  "title": "Audio Player",
  "description": "A minimal token-styled audio player card — play/pause, seek, and mm:ss time over a native <audio> element.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/audio-player.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Pause, Play } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface AudioPlayerProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Audio file URL. */\n  src: string\n  /** Optional label rendered above the seek bar (podcast episode, track name, sender…). */\n  title?: string\n  /** Native <audio> preload hint. */\n  preload?: \"none\" | \"metadata\"\n}\n\nfunction formatTime(seconds: number) {\n  if (!Number.isFinite(seconds) || seconds < 0) return \"0:00\"\n  const m = Math.floor(seconds / 60)\n  const s = Math.floor(seconds % 60)\n  return `${m}:${s.toString().padStart(2, \"0\")}`\n}\n\nexport const AudioPlayer = React.forwardRef<HTMLDivElement, AudioPlayerProps>(\n  ({ src, title, preload = \"metadata\", className, ...props }, ref) => {\n    const audioRef = React.useRef<HTMLAudioElement | null>(null)\n    const [playing, setPlaying] = React.useState(false)\n    const [currentTime, setCurrentTime] = React.useState(0)\n    const [duration, setDuration] = React.useState(0)\n    const [error, setError] = React.useState(false)\n\n    // src 变化(播放列表复用同一实例)时重置全部派生状态:否则上一首的\n    // error/进度会残留,坏源之后的好源永远被禁用。渲染期 adjust-state 模式。\n    const [prevSrc, setPrevSrc] = React.useState(src)\n    if (prevSrc !== src) {\n      setPrevSrc(src)\n      setPlaying(false)\n      setCurrentTime(0)\n      setDuration(0)\n      setError(false)\n    }\n\n    // Unmounting mid-playback shouldn't leave the clip running in the background.\n    React.useEffect(() => {\n      const audio = audioRef.current\n      return () => audio?.pause()\n    }, [])\n\n    const togglePlay = () => {\n      const audio = audioRef.current\n      if (!audio || error) return\n      if (audio.paused) {\n        audio.play().catch(() => setError(true))\n      } else {\n        audio.pause()\n      }\n    }\n\n    const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {\n      const next = Number(e.target.value)\n      if (audioRef.current) audioRef.current.currentTime = next\n      setCurrentTime(next)\n    }\n\n    const progress = duration > 0 ? (currentTime / duration) * 100 : 0\n\n    return (\n      <div\n        className={cn(\"flex items-center gap-3 rounded-xl border bg-card px-4 py-3\", className)}\n        ref={ref}\n        {...props}\n      >\n        <audio\n          onEnded={() => {\n            setPlaying(false)\n            setCurrentTime(0)\n            if (audioRef.current) audioRef.current.currentTime = 0\n          }}\n          onError={() => setError(true)}\n          onLoadedMetadata={e => setDuration(e.currentTarget.duration || 0)}\n          onPause={() => setPlaying(false)}\n          onPlay={() => setPlaying(true)}\n          onTimeUpdate={e => setCurrentTime(e.currentTarget.currentTime)}\n          preload={preload}\n          ref={audioRef}\n          src={src}\n        />\n\n        <button\n          aria-label={playing ? \"Pause\" : \"Play\"}\n          className={cn(\n            \"flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-colors\",\n            \"hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n            \"disabled:pointer-events-none disabled:opacity-50\",\n          )}\n          disabled={error}\n          onClick={togglePlay}\n          type=\"button\"\n        >\n          {playing ? (\n            <Pause aria-hidden=\"true\" className=\"size-4 fill-current\" />\n          ) : (\n            <Play aria-hidden=\"true\" className=\"size-4 translate-x-0.5 fill-current\" />\n          )}\n        </button>\n\n        <div className=\"min-w-0 flex-1\">\n          {title && <p className=\"mb-1 truncate text-sm font-medium text-foreground\">{title}</p>}\n\n          {error ? (\n            <p className=\"text-xs text-destructive\">Couldn&apos;t load audio</p>\n          ) : (\n            <div className=\"flex items-center gap-2\">\n              <input\n                aria-label=\"Seek\"\n                aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}\n                className={cn(\n                  \"h-4 w-full cursor-pointer appearance-none rounded-full bg-transparent\",\n                  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                  \"disabled:cursor-default\",\n                  // Track: thin token-colored rail, played portion painted via a CSS-var-driven\n                  // gradient so no JS re-renders the DOM node, only the custom property changes.\n                  \"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full\",\n                  \"[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--primary)_var(--ap-progress),var(--muted)_var(--ap-progress))]\",\n                  \"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full\",\n                  \"[&::-moz-range-track]:bg-[linear-gradient(to_right,var(--primary)_var(--ap-progress),var(--muted)_var(--ap-progress))]\",\n                  // Thumb: re-skinned circle; Firefox centers on the track automatically,\n                  // WebKit needs a negative margin-top to vertically center against the thin track.\n                  \"[&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none\",\n                  \"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary\",\n                  \"[&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-primary\",\n                )}\n                disabled={!duration}\n                max={duration || 0}\n                min={0}\n                onChange={handleSeek}\n                step={0.01}\n                style={{ \"--ap-progress\": `${progress}%` } as React.CSSProperties}\n                type=\"range\"\n                value={currentTime}\n              />\n              <span className=\"shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground\">\n                {formatTime(currentTime)} / {formatTime(duration)}\n              </span>\n            </div>\n          )}\n        </div>\n      </div>\n    )\n  },\n)\n\nAudioPlayer.displayName = \"AudioPlayer\"\n\nexport default AudioPlayer\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}