{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wave-recorder",
  "title": "Wave Recorder",
  "description": "Voice message recorder with live waveform, pause/resume, discard and auto-stop.",
  "dependencies": [
    "wavesurfer.js",
    "lucide-react"
  ],
  "registryDependencies": [
    "@waves-cn/wave-cn",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/components/wave-recorder.tsx",
      "content": "\"use client\";\n\nimport {\n  useRef,\n  useState,\n  useEffect,\n  useCallback,\n  type CSSProperties,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Mic, Pause, Play, Square, Trash2 } from \"lucide-react\";\nimport RecordPlugin, {\n  type RecordPluginOptions,\n  type RecordPluginDeviceOptions,\n} from \"wavesurfer.js/dist/plugins/record.esm.js\";\nimport { cn } from \"@/lib/utils\";\nimport { formatTime, useCssVar, useWavesurfer } from \"@/lib/wave-cn\";\n\n// Types\n\nexport type RecordState = \"idle\" | \"recording\" | \"paused\" | \"done\";\n\nexport type WaveRecorderProps = {\n  // Callbacks\n  onRecordEnd?: (blob: Blob) => void;\n  onRecordStart?: () => void;\n  onRecordPause?: () => void;\n  onRecordResume?: () => void;\n  onDiscard?: () => void;\n  onError?: (error: Error) => void;\n\n  // Behaviour\n  maxDuration?: number;\n  mimeType?: RecordPluginOptions[\"mimeType\"];\n  audioBitsPerSecond?: RecordPluginOptions[\"audioBitsPerSecond\"]; // default: 128000\n  deviceId?: string;\n  disabled?: boolean;\n\n  // Display\n  showWaveform?: boolean; // default: true\n  showTimer?: boolean; // default: true\n  waveColor?: string;\n  progressColor?: string;\n  waveformHeight?: number; // default: 64\n  barWidth?: number; // default: 3\n  barGap?: number; // default: 2\n  barRadius?: number; // default: 30\n  barHeight?: number; // default: 0.8\n\n  // Style\n  className?: string;\n  style?: CSSProperties;\n  waveformClassName?: string;\n  timerClassName?: string;\n  controlsClassName?: string;\n};\n\ntype RecordPluginInstance = InstanceType<typeof RecordPlugin>;\n\nexport function WaveRecorder({\n  onRecordEnd,\n  onRecordStart,\n  onRecordPause,\n  onRecordResume,\n  onDiscard,\n  onError,\n  maxDuration,\n  mimeType,\n  audioBitsPerSecond = 128000,\n  deviceId,\n  disabled = false,\n  showWaveform = true,\n  showTimer = true,\n  waveColor = \"var(--primary)\",\n  progressColor = \"var(--background)\",\n  waveformHeight = 64,\n  barWidth = 3,\n  barGap = 2,\n  barRadius = 2,\n  barHeight,\n  className,\n  style,\n  waveformClassName,\n  timerClassName,\n  controlsClassName,\n}: WaveRecorderProps) {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  // Live plugin instance. Kept in a ref (not state): it is only read from\n  // event handlers, and the plugin effect must not call setState synchronously.\n  const recordRef = useRef<RecordPluginInstance | null>(null);\n  const [recordState, setRecordState] = useState<RecordState>(\"idle\");\n  const [duration, setDuration] = useState(0);\n\n  const isDiscarding = useRef(false);\n  const maxDurationTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Latest callback props, read at event time so the plugin effect never\n  // needs to re-run when a parent passes a new inline function.\n  const callbacks = useRef({\n    onRecordEnd,\n    onRecordStart,\n    onRecordPause,\n    onRecordResume,\n    onDiscard,\n  });\n  useEffect(() => {\n    callbacks.current = {\n      onRecordEnd,\n      onRecordStart,\n      onRecordPause,\n      onRecordResume,\n      onDiscard,\n    };\n  });\n\n  const resolvedWaveColor = useCssVar(waveColor);\n  const resolvedProgressColor = useCssVar(progressColor);\n\n  const { wavesurfer } = useWavesurfer({\n    container: containerRef,\n    waveColor: resolvedWaveColor,\n    progressColor: resolvedProgressColor,\n    height: waveformHeight,\n    barWidth,\n    barGap,\n    barRadius,\n    barHeight,\n  });\n\n  const clearMaxDurationTimer = useCallback(() => {\n    if (maxDurationTimer.current) {\n      clearTimeout(maxDurationTimer.current);\n      maxDurationTimer.current = null;\n    }\n  }, []);\n\n  // Plugin lifecycle + events\n  useEffect(() => {\n    if (!wavesurfer) return;\n\n    const record = wavesurfer.registerPlugin(\n      RecordPlugin.create({\n        renderRecordedAudio: false,\n        continuousWaveform: false,\n        scrollingWaveform: true,\n        mimeType,\n        audioBitsPerSecond,\n        mediaRecorderTimeslice: 100,\n      }),\n    );\n    recordRef.current = record;\n\n    const unsubs = [\n      // Only re-render when the displayed second changes.\n      record.on(\"record-progress\", (ms) => {\n        setDuration((prev) =>\n          Math.floor(ms / 1000) === Math.floor(prev / 1000) ? prev : ms,\n        );\n      }),\n\n      record.on(\"record-start\", () => {\n        setRecordState(\"recording\");\n        setDuration(0);\n        callbacks.current.onRecordStart?.();\n      }),\n\n      record.on(\"record-pause\", () => {\n        setRecordState(\"paused\");\n        callbacks.current.onRecordPause?.();\n      }),\n\n      record.on(\"record-resume\", () => {\n        setRecordState(\"recording\");\n        callbacks.current.onRecordResume?.();\n      }),\n\n      record.on(\"record-end\", (blob: Blob) => {\n        if (maxDurationTimer.current) {\n          clearTimeout(maxDurationTimer.current);\n          maxDurationTimer.current = null;\n        }\n        if (!isDiscarding.current) {\n          callbacks.current.onRecordEnd?.(blob);\n          setRecordState(\"done\");\n        } else {\n          callbacks.current.onDiscard?.();\n          setRecordState(\"idle\");\n        }\n        isDiscarding.current = false;\n        wavesurfer.empty();\n        setDuration(0);\n      }),\n    ];\n\n    return () => {\n      unsubs.forEach((fn) => fn());\n      if (maxDurationTimer.current) {\n        clearTimeout(maxDurationTimer.current);\n        maxDurationTimer.current = null;\n      }\n      if (record.isActive()) record.stopRecording();\n      record.stopMic();\n      record.destroy();\n      if (recordRef.current === record) recordRef.current = null;\n    };\n  }, [wavesurfer, mimeType, audioBitsPerSecond]);\n\n  // Actions\n\n  const start = useCallback(async () => {\n    const record = recordRef.current;\n    if (!record || disabled) return;\n    try {\n      const deviceOptions: RecordPluginDeviceOptions = deviceId\n        ? { deviceId: { exact: deviceId } }\n        : {};\n      await record.startRecording(deviceOptions);\n      if (maxDuration && maxDuration > 0) {\n        maxDurationTimer.current = setTimeout(\n          () => record.stopRecording(),\n          maxDuration * 1000,\n        );\n      }\n    } catch (err) {\n      onError?.(err instanceof Error ? err : new Error(String(err)));\n    }\n  }, [disabled, deviceId, maxDuration, onError]);\n\n  const stop = useCallback(() => {\n    clearMaxDurationTimer();\n    recordRef.current?.stopRecording();\n  }, [clearMaxDurationTimer]);\n\n  const togglePause = useCallback(() => {\n    const record = recordRef.current;\n    if (!record) return;\n    if (recordState === \"paused\") {\n      record.resumeRecording();\n    } else {\n      record.pauseRecording();\n    }\n  }, [recordState]);\n\n  const discard = useCallback(() => {\n    clearMaxDurationTimer();\n    isDiscarding.current = true;\n    recordRef.current?.stopRecording();\n    wavesurfer?.empty();\n    setRecordState(\"idle\");\n    setDuration(0);\n  }, [clearMaxDurationTimer, wavesurfer]);\n\n  // Derived\n\n  const isActive = recordState === \"recording\" || recordState === \"paused\";\n  const isPaused = recordState === \"paused\";\n\n  // Render\n\n  return (\n    <div className={cn(\"w-full space-y-3 \", className)} style={style}>\n      {/* Waveform — always mounted for stable DOM ref, hidden when idle */}\n      <div\n        className={cn(\n          \"flex items-center w-full \",\n          (!showWaveform && !showTimer) || !isActive ? \"hidden\" : \"opacity-100\",\n        )}\n      >\n        <div\n          ref={containerRef}\n          aria-hidden=\"true\"\n          className={cn(\n            \"overflow-hidden transition-all duration-300 w-full\",\n            showWaveform && isActive\n              ? \"opacity-100 mb-2\"\n              : \"opacity-0 h-0 pointer-events-none\",\n            waveformClassName,\n          )}\n          style={\n            showWaveform && isActive ? { height: waveformHeight } : undefined\n          }\n        />\n      </div>\n\n      {/* Controls */}\n\n      {showTimer && isActive && (\n        <p\n          className={cn(\n            \"text-base tabular-nums text-muted-foreground shrink-0 text-center\",\n            timerClassName,\n          )}\n        >\n          {formatTime(duration / 1000)}\n        </p>\n      )}\n      <div\n        className={cn(\n          \"flex items-center gap-2 justify-center\",\n          controlsClassName,\n        )}\n      >\n        {!isActive && (\n          <Button\n            size=\"icon\"\n            variant=\"secondary\"\n            onClick={start}\n            disabled={disabled}\n            aria-label=\"Start recording\"\n          >\n            <Mic className=\"size-4\" />\n          </Button>\n        )}\n\n        {isActive && (\n          <>\n            <Button\n              size=\"icon\"\n              variant=\"outline\"\n              onClick={discard}\n              disabled={disabled}\n              aria-label=\"Discard recording\"\n              className=\"hover:text-destructive hover:bg-destructive/10\"\n            >\n              <Trash2 className=\"size-4\" />\n            </Button>\n\n            <Button\n              size=\"icon\"\n              variant=\"destructive\"\n              onClick={stop}\n              disabled={disabled}\n              aria-label=\"Stop recording\"\n            >\n              <Square className=\"size-4\" />\n            </Button>\n\n            <Button\n              size=\"icon\"\n              variant=\"outline\"\n              onClick={togglePause}\n              disabled={disabled}\n              aria-label={isPaused ? \"Resume recording\" : \"Pause recording\"}\n            >\n              {isPaused ? (\n                <Play className=\"size-4\" />\n              ) : (\n                <Pause className=\"size-4\" />\n              )}\n            </Button>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default WaveRecorder;\n",
      "type": "registry:component",
      "target": "components/waves-cn/wave-recorder.tsx"
    }
  ],
  "type": "registry:component"
}