{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wave-timeline",
  "title": "Wave Timeline",
  "description": "wave player with timeline ruler, zoom slider and volume control powered by wavesurfer.js TimelinePlugin.",
  "dependencies": [
    "wavesurfer.js",
    "lucide-react"
  ],
  "registryDependencies": [
    "@waves-cn/wave-cn",
    "button",
    "card",
    "slider"
  ],
  "files": [
    {
      "path": "src/registry/components/wave-timeline.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport TimelinePlugin from \"wavesurfer.js/dist/plugins/timeline.esm.js\";\nimport { cn } from \"@/lib/utils\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Slider } from \"@/components/ui/slider\";\nimport {\n  Play,\n  Pause,\n  Volume2,\n  VolumeX,\n  RotateCcw,\n  ZoomIn,\n  ZoomOut,\n} from \"lucide-react\";\nimport WavesurferPlayer, { formatTime, useWavePlayer } from \"@/lib/wave-cn\";\n\n// ─── Types\nexport interface TimelineOptions {\n  height?: number;\n  timeInterval?: number;\n  primaryLabelInterval?: number;\n  secondaryLabelInterval?: number;\n  fontSize?: string;\n}\n\nexport interface WaveTimelineProps {\n  src: string;\n  title?: string;\n  defaultVolume?: number;\n  waveColor?: string;\n  progressColor?: string;\n  barWidth?: number;\n  barGap?: number;\n  barRadius?: number;\n  waveHeight?: number;\n  defaultZoom?: number;\n  minZoom?: number;\n  maxZoom?: number;\n  topTimeline?: TimelineOptions | false;\n  bottomTimeline?: TimelineOptions | false;\n  onPlay?: () => void;\n  onPause?: () => void;\n  onFinish?: () => void;\n  onTimeUpdate?: (currentTime: number, duration: number) => void;\n  className?: string;\n}\n\n// ─── Component\nexport function WaveTimeline({\n  src,\n  title,\n  defaultVolume = 0.8,\n  waveColor,\n  progressColor,\n  barWidth = 2,\n  barGap = 1,\n  barRadius = 2,\n  waveHeight = 80,\n  defaultZoom = 50,\n  minZoom = 10,\n  maxZoom = 500,\n  topTimeline = {},\n  bottomTimeline = false,\n  onPlay,\n  onPause,\n  onFinish,\n  onTimeUpdate,\n  className,\n}: WaveTimelineProps) {\n  const player = useWavePlayer({\n    defaultVolume,\n    onPlay,\n    onPause,\n    onFinish,\n    onTimeUpdate,\n  });\n  const {\n    isReady,\n    isPlaying,\n    currentTime,\n    duration,\n    progress,\n    volume,\n    isMuted,\n  } = player;\n\n  const [zoom, setZoom] = React.useState(defaultZoom);\n\n  // ── Memoized plugins ──────────────────────────────────────────────────────\n  // Stable array reference — WavesurferPlayer uses reference equality to decide\n  // whether to recreate the instance, so this must not change on every render.\n  // Option fields are destructured into primitives so the memo deps stay simple.\n  const topOn = topTimeline !== false;\n  const topHeight = topOn ? topTimeline.height : undefined;\n  const topTimeInterval = topOn ? topTimeline.timeInterval : undefined;\n  const topPrimaryLabelInterval = topOn\n    ? topTimeline.primaryLabelInterval\n    : undefined;\n  const topSecondaryLabelInterval = topOn\n    ? topTimeline.secondaryLabelInterval\n    : undefined;\n  const topFontSize = topOn ? topTimeline.fontSize : undefined;\n\n  const bottomOn = bottomTimeline !== false;\n  const bottomHeight = bottomOn ? bottomTimeline.height : undefined;\n  const bottomTimeInterval = bottomOn ? bottomTimeline.timeInterval : undefined;\n  const bottomPrimaryLabelInterval = bottomOn\n    ? bottomTimeline.primaryLabelInterval\n    : undefined;\n  const bottomSecondaryLabelInterval = bottomOn\n    ? bottomTimeline.secondaryLabelInterval\n    : undefined;\n  const bottomFontSize = bottomOn ? bottomTimeline.fontSize : undefined;\n\n  const plugins = React.useMemo(() => {\n    if (typeof document === \"undefined\") return [];\n    const list: InstanceType<typeof TimelinePlugin>[] = [];\n\n    if (topOn) {\n      list.push(\n        TimelinePlugin.create({\n          height: topHeight ?? 20,\n          insertPosition: \"beforebegin\",\n          timeInterval: topTimeInterval ?? 0.5,\n          primaryLabelInterval: topPrimaryLabelInterval ?? 5,\n          secondaryLabelInterval: topSecondaryLabelInterval ?? 1,\n          style: {\n            fontSize: topFontSize ?? \"11px\",\n            color: \"var(--muted-foreground)\",\n            background: \"var(--muted)\",\n          },\n        }),\n      );\n    }\n\n    if (bottomOn) {\n      list.push(\n        TimelinePlugin.create({\n          height: bottomHeight ?? 14,\n          timeInterval: bottomTimeInterval ?? 0.1,\n          primaryLabelInterval: bottomPrimaryLabelInterval ?? 1,\n          secondaryLabelInterval: bottomSecondaryLabelInterval,\n          style: {\n            fontSize: bottomFontSize ?? \"10px\",\n            color: \"var(--muted-foreground)\",\n            background: \"var(--muted)\",\n          },\n        }),\n      );\n    }\n\n    return list;\n  }, [\n    topOn,\n    topHeight,\n    topTimeInterval,\n    topPrimaryLabelInterval,\n    topSecondaryLabelInterval,\n    topFontSize,\n    bottomOn,\n    bottomHeight,\n    bottomTimeInterval,\n    bottomPrimaryLabelInterval,\n    bottomSecondaryLabelInterval,\n    bottomFontSize,\n  ]);\n\n  // ── Zoom ──────────────────────────────────────────────────────────────────\n  // Slider changes are coalesced to one `ws.zoom()` call per animation frame;\n  // the slider position (`zoom` state) updates immediately.\n  const zoomFrame = React.useRef<number | null>(null);\n  const pendingZoom = React.useRef(defaultZoom);\n\n  const applyZoom = (value: number) => {\n    pendingZoom.current = value;\n    if (zoomFrame.current !== null) return;\n    zoomFrame.current = requestAnimationFrame(() => {\n      zoomFrame.current = null;\n      player.wavesurfer.current?.zoom(pendingZoom.current);\n    });\n  };\n\n  React.useEffect(\n    () => () => {\n      if (zoomFrame.current !== null) cancelAnimationFrame(zoomFrame.current);\n    },\n    [],\n  );\n\n  const handleZoom = ([value]: number[]) => {\n    setZoom(value);\n    applyZoom(value);\n  };\n\n  const zoomIn = () => {\n    const next = Math.min(zoom * 1.5, maxZoom);\n    setZoom(next);\n    player.wavesurfer.current?.zoom(next);\n  };\n\n  const zoomOut = () => {\n    const next = Math.max(zoom / 1.5, minZoom);\n    setZoom(next);\n    player.wavesurfer.current?.zoom(next);\n  };\n\n  // ── Playback ──────────────────────────────────────────────────────────────\n  const handleSeek = ([v]: number[]) => player.seek(v);\n  const handleVolume = ([v]: number[]) => player.setVolume(v);\n\n  // ── Render ────────────────────────────────────────────────────────────────\n  return (\n    <Card\n      className={cn(\n        \"w-full px-0 border-none rounded-none bg-transparent\",\n        className,\n      )}\n    >\n      <CardContent className=\" space-y-3 p-0\">\n        {/* Title */}\n        {title && (\n          <p className=\"text-sm font-medium text-foreground truncate\">\n            {title}\n          </p>\n        )}\n\n        {/* Waveform + timeline */}\n        <div className=\"relative w-full rounded-sm overflow-hidden border border-border\">\n          <WavesurferPlayer\n            url={src}\n            waveColor={waveColor}\n            progressColor={progressColor}\n            height={waveHeight}\n            barWidth={barWidth}\n            barGap={barGap}\n            barRadius={barRadius}\n            minPxPerSec={defaultZoom}\n            fillParent\n            dragToSeek\n            hideScrollbar={false}\n            plugins={plugins}\n            {...player.handlers}\n          />\n        </div>\n\n        {/* Seek bar */}\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-[11px] tabular-nums text-muted-foreground w-10 text-right shrink-0\">\n            {formatTime(currentTime)}\n          </span>\n          <Slider\n            className=\"flex-1\"\n            value={[progress]}\n            min={0}\n            max={1}\n            step={0.001}\n            disabled={!isReady}\n            onValueChange={handleSeek}\n            aria-label=\"Seek\"\n          />\n          <span className=\"text-[11px] tabular-nums text-muted-foreground w-10 shrink-0\">\n            {formatTime(duration)}\n          </span>\n        </div>\n\n        {/* Controls */}\n        <div className=\"flex items-center justify-between gap-3 flex-wrap\">\n          {/* Playback */}\n          <div className=\"flex items-center gap-1.5\">\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-8 w-8 text-muted-foreground hover:text-foreground\"\n              disabled={!isReady}\n              onClick={player.restart}\n              aria-label=\"Restart\"\n            >\n              <RotateCcw size={15} />\n            </Button>\n            <Button\n              size=\"icon\"\n              variant=\"secondary\"\n              className=\"h-9 w-9\"\n              disabled={!isReady}\n              onClick={player.togglePlay}\n              aria-label={isPlaying ? \"Pause\" : \"Play\"}\n            >\n              {isPlaying ? <Pause size={17} /> : <Play size={17} />}\n            </Button>\n          </div>\n\n          {/* Zoom */}\n          <div className=\"flex items-center gap-2 flex-1 max-w-50\">\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground\"\n              disabled={!isReady || zoom <= minZoom}\n              onClick={zoomOut}\n              aria-label=\"Zoom out\"\n            >\n              <ZoomOut size={15} />\n            </Button>\n            <Slider\n              value={[zoom]}\n              min={minZoom}\n              max={maxZoom}\n              step={1}\n              disabled={!isReady}\n              onValueChange={handleZoom}\n              aria-label=\"Zoom\"\n            />\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground\"\n              disabled={!isReady || zoom >= maxZoom}\n              onClick={zoomIn}\n              aria-label=\"Zoom in\"\n            >\n              <ZoomIn size={15} />\n            </Button>\n          </div>\n\n          {/* Volume */}\n          <div className=\"flex items-center gap-2 w-32\">\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground\"\n              onClick={player.toggleMute}\n              aria-label={isMuted ? \"Unmute\" : \"Mute\"}\n            >\n              {isMuted ? <VolumeX size={15} /> : <Volume2 size={15} />}\n            </Button>\n            <Slider\n              value={[isMuted ? 0 : volume]}\n              min={0}\n              max={1}\n              step={0.01}\n              onValueChange={handleVolume}\n              aria-label=\"Volume\"\n            />\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\nexport default WaveTimeline;\n",
      "type": "registry:component",
      "target": "components/waves-cn/wave-timeline.tsx"
    }
  ],
  "type": "registry:component"
}