{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waveform-player",
  "type": "registry:ui",
  "title": "Waveform Player",
  "description": "A compact audio player with a bar waveform you can click or drag to scrub, a morphing play button and a real slider for keyboards.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/waveform-player.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nexport interface WaveformPlayerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  /** Waveform peaks, any positive scale (normalised to the largest value). One bar per entry. */\n  peaks: number[];\n  /** Audio file URL. Ignored when `synth` is on. */\n  src?: string;\n  /** Play a generated Web Audio melody instead of a file (no assets needed). */\n  synth?: boolean;\n  /** Length of the synth melody in seconds. */\n  synthDuration?: number;\n  /** Track title, shown above the waveform and used in labels. */\n  title?: string;\n  /** Secondary line, e.g. the artist. */\n  subtitle?: string;\n  /** Seconds moved by the arrow keys. */\n  seekStep?: number;\n  /** Waveform height in px. */\n  height?: number;\n  /** Gap between bars in px. */\n  barGap?: number;\n  /** Show elapsed / total time. */\n  showTime?: boolean;\n  variant?: \"card\" | \"bare\";\n  /** Played bars, play button and focus rings. */\n  accent?: string;\n  /** Called when playback starts or stops. */\n  onPlayChange?: (playing: boolean) => void;\n}\n\n/** Common interface for the <audio> element and the Web Audio synth. */\ninterface Engine {\n  play(): Promise<void>;\n  pause(): void;\n  seek(time: number): void;\n  time(): number;\n  destroy(): void;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction formatTime(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds));\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const sec = String(s % 60).padStart(2, \"0\");\n  return h > 0 ? `${h}:${String(m).padStart(2, \"0\")}:${sec}` : `${m}:${sec}`;\n}\n\n/* ---------------------------------- synth ---------------------------------- */\n\n// A minor pentatonic progression (Am, F, C, G), one chord per 2-second bar, sixteenth-note arpeggios.\nconst CHORDS = [\n  [57, 60, 64, 69],\n  [53, 57, 60, 65],\n  [48, 55, 60, 64],\n  [55, 59, 62, 67],\n];\nconst PATTERN = [0, 1, 2, 3, 2, 1, 3, 2];\nconst STEP = 0.25; // seconds per note\nconst midiToHz = (midi: number) => 440 * Math.pow(2, (midi - 69) / 12);\n\nfunction createSynth(duration: number, onEnded: () => void): Engine {\n  let ctx: AudioContext | null = null;\n  let bus: GainNode | null = null;\n  let voices: OscillatorNode[] = [];\n  let startedAt = 0; // context time that corresponds to position 0\n  let offset = 0; // position while paused\n  let playing = false;\n  let endTimer = 0;\n\n  const stopVoices = () => {\n    window.clearTimeout(endTimer);\n    if (!ctx || !bus) return;\n    const now = ctx.currentTime;\n    const old = bus;\n    old.gain.cancelScheduledValues(now);\n    old.gain.setValueAtTime(old.gain.value, now);\n    old.gain.linearRampToValueAtTime(0, now + 0.04);\n    for (const v of voices) {\n      try {\n        v.stop(now + 0.05);\n      } catch {\n        // already stopped\n      }\n    }\n    window.setTimeout(() => old.disconnect(), 120);\n    voices = [];\n    bus = null;\n  };\n\n  /** Schedules every note from `from` seconds onwards. */\n  const schedule = (from: number) => {\n    if (!ctx) return;\n    const now = ctx.currentTime + 0.03;\n    bus = ctx.createGain();\n    bus.gain.value = 0.55;\n    bus.connect(ctx.destination);\n    const out = bus;\n    const note = (time: number, midi: number, length: number, type: OscillatorType, level: number) => {\n      if (time + length < from || time >= duration || !ctx) return;\n      const at = now + Math.max(0, time - from);\n      const osc = ctx.createOscillator();\n      const env = ctx.createGain();\n      osc.type = type;\n      osc.frequency.value = midiToHz(midi);\n      env.gain.setValueAtTime(0.0001, at);\n      env.gain.exponentialRampToValueAtTime(level, at + 0.012);\n      env.gain.exponentialRampToValueAtTime(0.0001, at + length);\n      osc.connect(env).connect(out);\n      osc.start(at);\n      osc.stop(at + length + 0.02);\n      voices.push(osc);\n    };\n    const bars = Math.ceil(duration / (STEP * PATTERN.length));\n    for (let bar = 0; bar < bars; bar++) {\n      const chord = CHORDS[bar % CHORDS.length];\n      const barStart = bar * STEP * PATTERN.length;\n      note(barStart, chord[0] - 12, 1.9, \"sine\", 0.16);\n      PATTERN.forEach((index, i) => {\n        // Lift every other bar an octave for a little shape.\n        const lift = bar % 4 === 3 && i > 3 ? 12 : 0;\n        note(barStart + i * STEP, chord[index] + lift, 0.42, \"triangle\", i % 4 === 0 ? 0.12 : 0.08);\n      });\n    }\n    startedAt = now - from;\n  };\n\n  return {\n    async play() {\n      if (!ctx) {\n        const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;\n        ctx = new Ctor();\n      }\n      await ctx.resume();\n      stopVoices();\n      if (offset >= duration - 0.05) offset = 0;\n      schedule(offset);\n      playing = true;\n      endTimer = window.setTimeout(() => {\n        stopVoices();\n        playing = false;\n        offset = 0;\n        onEnded();\n      }, (duration - offset) * 1000);\n    },\n    pause() {\n      offset = this.time();\n      playing = false;\n      stopVoices();\n    },\n    seek(time) {\n      offset = Math.min(Math.max(0, time), duration);\n      if (playing) void this.play();\n    },\n    time() {\n      return playing && ctx ? Math.min(duration, Math.max(0, ctx.currentTime - startedAt)) : offset;\n    },\n    destroy() {\n      stopVoices();\n      playing = false;\n      void ctx?.close();\n      ctx = null;\n    },\n  };\n}\n\nfunction createAudioEngine(audio: HTMLAudioElement): Engine {\n  return {\n    play: () => audio.play(),\n    pause: () => audio.pause(),\n    seek: (time) => {\n      audio.currentTime = time;\n    },\n    time: () => audio.currentTime,\n    destroy: () => {\n      audio.pause();\n      audio.removeAttribute(\"src\");\n      audio.load();\n    },\n  };\n}\n\n/* --------------------------------- player ---------------------------------- */\n\n// Play / pause as two quads each, so the icon can morph between them point by point.\nconst PLAY = [\"M7 4.5L13 8.25L13 15.75L7 19.5Z\", \"M13 8.25L19 12L19 12L13 15.75Z\"];\nconst PAUSE = [\"M6.5 5L10 5L10 19L6.5 19Z\", \"M14 5L17.5 5L17.5 19L14 19Z\"];\n\n/**\n * A compact audio player with a bar waveform you can click or drag to scrub. The played portion is\n * highlighted, hovering previews the seek position and time, and the waveform is a real slider\n * (arrows seek, Space plays / pauses). Plays an audio `src`, or a generated Web Audio melody in\n * `synth` mode. Never autoplays, and stops when unmounted.\n */\nexport function WaveformPlayer({\n  peaks,\n  src,\n  synth = false,\n  synthDuration = 32,\n  title,\n  subtitle,\n  seekStep = 5,\n  height = 48,\n  barGap = 2,\n  showTime = true,\n  variant = \"card\",\n  accent = \"#FF7448\",\n  onPlayChange,\n  className = \"\",\n  style,\n  ...props\n}: WaveformPlayerProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const waveRef = React.useRef<HTMLDivElement>(null);\n  const hoverLabelRef = React.useRef<HTMLSpanElement>(null);\n  const elapsedRef = React.useRef<HTMLSpanElement>(null);\n  const engineRef = React.useRef<Engine | null>(null);\n  const scrubbing = React.useRef(false);\n  const [playing, setPlaying] = React.useState(false);\n  const [position, setPosition] = React.useState(0); // whole seconds, for ARIA and the time label\n  const [loadedDuration, setLoadedDuration] = React.useState(0);\n  const duration = synth ? synthDuration : loadedDuration;\n\n  // How many bars fit at ≥ 1.5px each; extra peaks are merged (bucket max) so narrow layouts never overflow.\n  const [capacity, setCapacity] = React.useState(Infinity);\n  React.useEffect(() => {\n    const wave = waveRef.current;\n    if (!wave) return;\n    const ro = new ResizeObserver(([entry]) => setCapacity(Math.max(8, Math.floor((entry.contentRect.width + barGap) / (barGap + 1.5)))));\n    ro.observe(wave);\n    return () => ro.disconnect();\n  }, [barGap]);\n  const sampled =\n    peaks.length <= capacity\n      ? peaks\n      : Array.from({ length: capacity }, (_, i) => {\n          const from = Math.floor((i * peaks.length) / capacity);\n          const to = Math.max(from + 1, Math.floor(((i + 1) * peaks.length) / capacity));\n          return Math.max(...peaks.slice(from, to));\n        });\n  const max = Math.max(0.0001, ...sampled);\n  const bars = sampled.map((p) => Math.round(Math.max(0.06, Math.min(1, p / max)) * 10000) / 100);\n\n  const setProgress = React.useCallback((fraction: number) => {\n    rootRef.current?.style.setProperty(\"--wp-p\", String(Math.round(Math.min(1, Math.max(0, fraction)) * 10000) / 10000));\n  }, []);\n\n  const onPlayChangeRef = React.useRef(onPlayChange);\n  React.useEffect(() => {\n    onPlayChangeRef.current = onPlayChange;\n  });\n\n  // Create the engine for the current source; tear it down (and stop audio) on change / unmount.\n  React.useEffect(() => {\n    setProgress(0);\n    const ended = () => {\n      setPlaying(false);\n      setPosition(0);\n      setProgress(0);\n      onPlayChangeRef.current?.(false);\n    };\n    if (synth) {\n      const engine = createSynth(synthDuration, ended);\n      engineRef.current = engine;\n      return () => {\n        engine.destroy();\n        engineRef.current = null;\n      };\n    }\n    if (!src) return;\n    const audio = new Audio();\n    audio.preload = \"metadata\";\n    const onMeta = () => setLoadedDuration(Number.isFinite(audio.duration) ? audio.duration : 0);\n    audio.addEventListener(\"loadedmetadata\", onMeta);\n    audio.addEventListener(\"ended\", ended);\n    audio.src = src;\n    const engine = createAudioEngine(audio);\n    engineRef.current = engine;\n    return () => {\n      audio.removeEventListener(\"loadedmetadata\", onMeta);\n      audio.removeEventListener(\"ended\", ended);\n      engine.destroy();\n      engineRef.current = null;\n    };\n  }, [src, synth, synthDuration, setProgress]);\n\n  // A new source starts stopped at 0:00.\n  const sourceKey = `${synth}:${synthDuration}:${src ?? \"\"}`;\n  const [prevSource, setPrevSource] = React.useState(sourceKey);\n  if (prevSource !== sourceKey) {\n    setPrevSource(sourceKey);\n    setPlaying(false);\n    setPosition(0);\n  }\n\n  // While playing (and on screen), move the progress every frame without re-rendering.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!playing || !root || duration <= 0) return;\n    let frame = 0;\n    let visible = true;\n    let lastSecond = -1;\n    const tick = () => {\n      const engine = engineRef.current;\n      if (engine && !scrubbing.current) {\n        const t = engine.time();\n        setProgress(t / duration);\n        const second = Math.floor(t);\n        if (second !== lastSecond) {\n          lastSecond = second;\n          setPosition(second);\n        }\n      }\n      frame = requestAnimationFrame(tick);\n    };\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      cancelAnimationFrame(frame);\n      if (visible) frame = requestAnimationFrame(tick);\n    });\n    io.observe(root);\n    frame = requestAnimationFrame(tick);\n    return () => {\n      io.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [playing, duration, setProgress]);\n\n  const toggle = async () => {\n    const engine = engineRef.current;\n    if (!engine) return;\n    if (playing) {\n      engine.pause();\n      setPlaying(false);\n      onPlayChange?.(false);\n      return;\n    }\n    try {\n      await engine.play();\n      setPlaying(true);\n      onPlayChange?.(true);\n    } catch {\n      // Playback was blocked or the source failed; stay paused.\n      setPlaying(false);\n    }\n  };\n\n  const seekTo = (time: number) => {\n    const t = Math.min(Math.max(0, time), duration);\n    engineRef.current?.seek(t);\n    setProgress(duration > 0 ? t / duration : 0);\n    setPosition(Math.floor(t));\n  };\n\n  const fractionAt = (clientX: number) => {\n    const rect = waveRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0) return 0;\n    return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n  };\n\n  /** Hover / scrub preview: CSS variable + label text, no React state per move. */\n  const preview = (fraction: number) => {\n    const wave = waveRef.current;\n    if (!wave) return;\n    wave.style.setProperty(\"--wp-h\", String(Math.round(fraction * 10000) / 10000));\n    if (hoverLabelRef.current) hoverLabelRef.current.textContent = formatTime(fraction * duration);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const current = engineRef.current?.time() ?? position;\n    let next: number | null = null;\n    switch (event.key) {\n      case \" \":\n      case \"k\":\n        event.preventDefault();\n        void toggle();\n        return;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = current - seekStep;\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = current + seekStep;\n        break;\n      case \"PageDown\":\n        next = current - duration / 10;\n        break;\n      case \"PageUp\":\n        next = current + duration / 10;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = duration;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    seekTo(next);\n  };\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 30 };\n  const label = title ? `${title}${subtitle ? `, ${subtitle}` : \"\"}` : \"Audio\";\n  const total = formatTime(duration);\n  const barRow = (extra: string, clipVar?: string) => (\n    <div\n      aria-hidden=\"true\"\n      className={`absolute inset-0 flex items-center ${extra}`}\n      style={{ gap: barGap, clipPath: clipVar ? `inset(0 calc(100% - var(${clipVar}, 0) * 100%) 0 0)` : undefined }}\n    >\n      {bars.map((h, i) => (\n        <span key={i} className=\"min-w-px flex-1 rounded-full bg-current\" style={{ height: `${h}%` }} />\n      ))}\n    </div>\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={`${label} player`}\n      className={`flex w-full min-w-0 items-center gap-3.5 ${variant === \"card\" ? \"rounded-2xl border border-zinc-900/10 bg-white p-3 pr-4 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_28px_-18px_rgb(0_0_0/0.3)] dark:border-white/10 dark:bg-[#16191c] dark:shadow-[0_14px_32px_-18px_rgb(0_0_0/0.8)]\" : \"\"} ${className}`}\n      style={{ [\"--wp-accent\" as string]: accent, [\"--wp-p\" as string]: 0, ...style }}\n      {...props}\n    >\n      <button\n        type=\"button\"\n        aria-label={playing ? `Pause ${label}` : `Play ${label}`}\n        disabled={!synth && !src}\n        onClick={() => void toggle()}\n        className=\"grid size-10 shrink-0 place-items-center rounded-full bg-[var(--wp-accent)] text-zinc-950 shadow-[inset_0_1px_0_rgb(255_255_255/0.35),0_6px_14px_-8px_var(--wp-accent)] outline-hidden transition-[scale,filter] duration-150 hover:brightness-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--wp-accent)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:active:scale-100\"\n      >\n        <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className=\"size-[18px]\" fill=\"currentColor\">\n          {[0, 1].map((i) => (\n            <motion.path key={i} initial={false} animate={{ d: playing ? PAUSE[i] : PLAY[i] }} transition={transition} />\n          ))}\n        </svg>\n      </button>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n        {title ? (\n          <div className=\"flex min-w-0 items-baseline gap-2\">\n            <span className=\"truncate text-[13px] font-medium tracking-tight text-zinc-900 dark:text-zinc-100\">{title}</span>\n            {subtitle ? <span className=\"truncate font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{subtitle}</span> : null}\n          </div>\n        ) : null}\n        <div\n          ref={waveRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={`Seek ${label}`}\n          aria-valuemin={0}\n          aria-valuemax={Math.round(duration)}\n          aria-valuenow={Math.min(position, Math.round(duration))}\n          aria-valuetext={`${formatTime(position)} of ${total}`}\n          aria-keyshortcuts=\"Space\"\n          onKeyDown={onKeyDown}\n          onPointerDown={(event) => {\n            if (event.button !== 0 || duration <= 0) return;\n            event.currentTarget.setPointerCapture(event.pointerId);\n            scrubbing.current = true;\n            const f = fractionAt(event.clientX);\n            preview(f);\n            setProgress(f);\n            if (elapsedRef.current) elapsedRef.current.textContent = formatTime(f * duration);\n          }}\n          onPointerMove={(event) => {\n            const f = fractionAt(event.clientX);\n            preview(f);\n            if (scrubbing.current) {\n              setProgress(f);\n              if (elapsedRef.current) elapsedRef.current.textContent = formatTime(f * duration);\n            }\n          }}\n          onPointerUp={(event) => {\n            if (!scrubbing.current) return;\n            scrubbing.current = false;\n            seekTo(fractionAt(event.clientX) * duration);\n          }}\n          onPointerCancel={() => {\n            scrubbing.current = false;\n            const t = engineRef.current?.time() ?? 0;\n            setProgress(duration > 0 ? t / duration : 0);\n            if (elapsedRef.current) elapsedRef.current.textContent = formatTime(t);\n          }}\n          className=\"group/wave relative w-full cursor-pointer touch-none select-none rounded-md outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--wp-accent)]\"\n          style={{ height }}\n        >\n          {barRow(\"text-zinc-900/[0.16] dark:text-white/[0.16]\")}\n          {/* Hover preview: up to the pointer, faint accent. */}\n          {barRow(\"text-[color-mix(in_oklab,var(--wp-accent)_40%,transparent)] opacity-0 transition-opacity duration-150 group-hover/wave:opacity-100\", \"--wp-h\")}\n          {/* Played portion. */}\n          {barRow(\"text-[var(--wp-accent)]\", \"--wp-p\")}\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute -inset-y-1 w-px -translate-x-1/2 rounded-full bg-zinc-900 opacity-0 transition-opacity duration-150 group-focus-visible/wave:opacity-100 dark:bg-white\"\n            style={{ left: \"calc(var(--wp-p) * 100%)\" }}\n          />\n          <span\n            ref={hoverLabelRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute -top-6 -translate-x-1/2 rounded bg-zinc-900 px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-white opacity-0 transition-opacity duration-150 dark:bg-zinc-100 dark:text-zinc-900 group-hover/wave:opacity-100\"\n            style={{ left: \"calc(var(--wp-h, 0) * 100%)\" }}\n          >\n            0:00\n          </span>\n        </div>\n      </div>\n\n      {showTime ? (\n        <div aria-hidden=\"true\" className=\"shrink-0 font-mono text-[11px] tabular-nums tracking-tight text-zinc-500 dark:text-zinc-400\">\n          <span ref={elapsedRef} className=\"text-zinc-900 dark:text-zinc-100\">\n            {formatTime(position)}\n          </span>\n          <span className=\"mx-1 opacity-50\">/</span>\n          {total}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/waveform-player"
}
