{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-message",
  "type": "registry:ui",
  "title": "Voice Message",
  "description": "A voice note whose waveform fills as it plays and seeks where you drag.",
  "categories": [
    "messaging"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/voice-message.tsx",
      "content": "\"use client\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, Pause, Play } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nconst RATES = [1, 1.5, 2] as const;\nexport type PlaybackRate = (typeof RATES)[number];\n\nconst clock = (seconds: number) => {\n  const s = Math.max(0, Math.floor(seconds + 0.001));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n};\nconst spoken = (seconds: number) => {\n  const s = Math.max(0, Math.round(seconds));\n  const m = Math.floor(s / 60);\n  return m ? `${m} minute${m === 1 ? \"\" : \"s\"} ${s % 60} second${s % 60 === 1 ? \"\" : \"s\"}` : `${s} second${s === 1 ? \"\" : \"s\"}`;\n};\n\n/** Squeezes any number of loudness samples into `count` bars, loudest-in-bucket, scaled so the peak fills the height. */\nexport function resamplePeaks(peaks: number[], count: number) {\n  if (!peaks.length) return Array.from({ length: count }, () => 0.12);\n  const out = Array.from({ length: count }, (_, i) => {\n    const from = Math.floor((i / count) * peaks.length);\n    const to = Math.max(from + 1, Math.floor(((i + 1) / count) * peaks.length));\n    let max = 0;\n    for (let j = from; j < to && j < peaks.length; j++) max = Math.max(max, Math.abs(peaks[j]));\n    return max;\n  });\n  const top = Math.max(...out, 0.001);\n  return out.map((v) => Math.max(0.08, v / top));\n}\n\n/** Decodes audio and measures its loudness, for when the server didn't send peaks. */\nasync function measure(src: string, signal: AbortSignal) {\n  const res = await fetch(src, { signal });\n  const data = await res.arrayBuffer();\n  const ctx = new OfflineAudioContext(1, 1, 44100);\n  const audio = await ctx.decodeAudioData(data);\n  const channel = audio.getChannelData(0);\n  const buckets = 96;\n  const size = Math.floor(channel.length / buckets) || 1;\n  return Array.from({ length: buckets }, (_, i) => {\n    let sum = 0;\n    for (let j = i * size; j < (i + 1) * size && j < channel.length; j++) sum += channel[j] * channel[j];\n    return Math.sqrt(sum / size);\n  });\n}\n\n// Only one voice message plays at a time, like every messaging app: starting one pauses the rest.\nlet playing: HTMLAudioElement | null = null;\n\nexport type VoiceMessageProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The audio file. Without it the player waits, disabled, with its waveform drawn. */\n  src?: string;\n  /** Loudness samples, 0–1, any count. Without them the audio is decoded once to draw the waveform. */\n  peaks?: number[];\n  /** Seconds. Used until the file reports its own length (recorded WebM often never does). */\n  duration?: number;\n  /** Your own message: the bubble fills with the foreground color. */\n  own?: boolean;\n  /** Whether it has been listened to. Unplayed shows a dot beside the time. */\n  played?: boolean;\n  defaultPlayed?: boolean;\n  onPlayedChange?: (played: boolean) => void;\n  rate?: PlaybackRate;\n  defaultRate?: PlaybackRate;\n  onRateChange?: (rate: PlaybackRate) => void;\n  /** Number of bars in the waveform. */\n  bars?: number;\n  /** Names the recording for screen readers: \"Voice message from Maya Chen\". */\n  label?: string;\n  /** How much to fetch before play. Metadata keeps a long thread cheap; auto suits local blobs. */\n  preload?: \"none\" | \"metadata\" | \"auto\";\n};\n\n/**\n * A voice note in a bubble. Play fills the waveform as it goes, the time counts up, the speed\n * cycles 1× · 1.5× · 2×, and dragging or clicking the waveform seeks.\n */\nexport function VoiceMessage({\n  src,\n  peaks: peaksProp,\n  duration: durationProp,\n  own = false,\n  played: playedProp,\n  defaultPlayed = false,\n  onPlayedChange,\n  rate: rateProp,\n  defaultRate = 1,\n  onRateChange,\n  bars = 36,\n  label = \"Voice message\",\n  preload = \"metadata\",\n  className,\n  ...rest\n}: VoiceMessageProps) {\n  const reduce = !!useReducedMotion();\n  const audioRef = useRef<HTMLAudioElement>(null);\n  const controlRef = useRef<HTMLDivElement>(null);\n  const [played, setPlayed] = useControllableState({ value: playedProp, defaultValue: defaultPlayed, onChange: onPlayedChange });\n  const [rate, setRate] = useControllableState<PlaybackRate>({ value: rateProp, defaultValue: defaultRate, onChange: onRateChange });\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [time, setTime] = useState(0);\n  const [fileDuration, setFileDuration] = useState<number | null>(null);\n  const [waiting, setWaiting] = useState(false);\n  const [failed, setFailed] = useState(false);\n  const [scrubbing, setScrubbing] = useState(false);\n  const [measured, setMeasured] = useState<number[] | null>(null);\n\n  const duration = fileDuration ?? durationProp ?? 0;\n  const progress = duration ? Math.min(1, time / duration) : 0;\n  const levels = useMemo(() => resamplePeaks(peaksProp ?? measured ?? [], bars), [peaksProp, measured, bars]);\n  const drawn = !!(peaksProp ?? measured);\n\n  /* ----- peaks: decode once when none were given ----- */\n  useEffect(() => {\n    if (peaksProp || !src) return;\n    const abort = new AbortController();\n    measure(src, abort.signal)\n      .then((p) => setMeasured(p))\n      .catch(() => {\n        /* A flat waveform is fine; playback doesn't depend on it. */\n      });\n    return () => abort.abort();\n  }, [src, peaksProp]);\n\n  /* ----- the audio element's events ----- */\n  useEffect(() => {\n    const a = audioRef.current;\n    if (!a) return;\n    let raf = 0;\n    const tick = () => {\n      setTime(a.currentTime);\n      raf = requestAnimationFrame(tick);\n    };\n    const onMeta = () => setFileDuration(Number.isFinite(a.duration) && a.duration > 0 ? a.duration : null);\n    const onPlay = () => {\n      setIsPlaying(true);\n      cancelAnimationFrame(raf);\n      raf = requestAnimationFrame(tick);\n    };\n    const onPause = () => {\n      setIsPlaying(false);\n      cancelAnimationFrame(raf);\n      setTime(a.currentTime);\n      if (playing === a) playing = null;\n    };\n    const onEnded = () => {\n      onPause();\n      // Back to the start, ready to play again, like a voice note should be.\n      a.currentTime = 0;\n      setTime(0);\n    };\n    const onWaiting = () => setWaiting(true);\n    const onPlaying = () => setWaiting(false);\n    const onError = () => {\n      setFailed(true);\n      setWaiting(false);\n    };\n    a.addEventListener(\"loadedmetadata\", onMeta);\n    a.addEventListener(\"durationchange\", onMeta);\n    a.addEventListener(\"play\", onPlay);\n    a.addEventListener(\"pause\", onPause);\n    a.addEventListener(\"ended\", onEnded);\n    a.addEventListener(\"waiting\", onWaiting);\n    a.addEventListener(\"playing\", onPlaying);\n    a.addEventListener(\"canplay\", onPlaying);\n    a.addEventListener(\"error\", onError);\n    return () => {\n      cancelAnimationFrame(raf);\n      a.pause();\n      a.removeEventListener(\"loadedmetadata\", onMeta);\n      a.removeEventListener(\"durationchange\", onMeta);\n      a.removeEventListener(\"play\", onPlay);\n      a.removeEventListener(\"pause\", onPause);\n      a.removeEventListener(\"ended\", onEnded);\n      a.removeEventListener(\"waiting\", onWaiting);\n      a.removeEventListener(\"playing\", onPlaying);\n      a.removeEventListener(\"canplay\", onPlaying);\n      a.removeEventListener(\"error\", onError);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (audioRef.current) audioRef.current.playbackRate = rate;\n  }, [rate]);\n\n  const toggle = useCallback(() => {\n    const a = audioRef.current;\n    if (!a || !src) return;\n    if (failed) {\n      setFailed(false);\n      a.load();\n    }\n    if (!a.paused) return a.pause();\n    if (playing && playing !== a) playing.pause();\n    playing = a;\n    a.playbackRate = rate;\n    setPlayed(true);\n    a.play().catch((e: unknown) => {\n      if (e instanceof DOMException && e.name === \"AbortError\") return;\n      setFailed(true);\n    });\n  }, [src, failed, rate, setPlayed]);\n\n  const seek = (seconds: number) => {\n    const a = audioRef.current;\n    const t = Math.max(0, Math.min(duration || 0, seconds));\n    if (a) a.currentTime = t;\n    setTime(t);\n  };\n\n  const cycleRate = () => setRate(RATES[(RATES.indexOf(rate) + 1) % RATES.length]);\n\n  // A spinner that flashes for 80ms reads as a glitch: buffering shows only after 150ms.\n  const [showWait, setShowWait] = useState(false);\n  useEffect(() => {\n    if (!waiting || !isPlaying) {\n      const t = window.setTimeout(() => setShowWait(false), 0);\n      return () => window.clearTimeout(t);\n    }\n    const t = window.setTimeout(() => setShowWait(true), 150);\n    return () => window.clearTimeout(t);\n  }, [waiting, isPlaying]);\n\n  const state = failed ? \"error\" : !src ? \"loading\" : isPlaying ? \"playing\" : \"paused\";\n  const shownTime = isPlaying || scrubbing || time > 0 ? time : duration;\n\n  return (\n    <div\n      data-own={own || undefined}\n      data-state={state}\n      data-bubble\n      role=\"group\"\n      aria-label={label}\n      className={cn(\n        \"inline-flex w-fit max-w-full select-none items-center gap-2.5 rounded-[20px] py-2 pl-2 pr-3\",\n        own ? \"bg-fg text-frame\" : \"bg-fg/[0.07] text-fg\",\n        className,\n      )}\n      {...rest}\n    >\n      <audio ref={audioRef} src={src} preload={preload} className=\"hidden\" />\n\n      {/* Play and pause, with a ring that turns only if the audio stalls for more than 150ms. */}\n      <button\n        type=\"button\"\n        onClick={toggle}\n        disabled={!src}\n        aria-label={failed ? \"Retry playing voice message\" : isPlaying ? \"Pause voice message\" : \"Play voice message\"}\n        className={cn(\n          \"relative grid size-9 shrink-0 place-items-center rounded-full outline-none\",\n          \"transition-[background-color,scale,opacity] duration-150 ease-out active:scale-[0.92] active:duration-75 disabled:opacity-50\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2\",\n          own ? \"bg-frame text-fg hover:bg-frame/90 focus-visible:outline-frame/60\" : \"bg-fg text-frame hover:bg-fg/90 focus-visible:outline-fg-3\",\n          failed && (own ? \"text-danger\" : \"bg-danger text-frame hover:bg-danger/90\"),\n          \"before:absolute before:-inset-1 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n        )}\n      >\n        <span className=\"relative grid size-4 place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={failed ? \"error\" : isPlaying ? \"pause\" : \"play\"}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\", transition: { duration: 0.1 } }}\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n            >\n              {failed ? <Alert /> : isPlaying ? <Pause /> : <Play className=\"translate-x-px\" />}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <AnimatePresence>\n          {showWait && (\n            <motion.svg\n              key=\"wait\"\n              aria-hidden\n              viewBox=\"0 0 40 40\"\n              className=\"pointer-events-none absolute -inset-1 size-[calc(100%+8px)] motion-safe:animate-spin\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            >\n              <circle cx=\"20\" cy=\"20\" r=\"18.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeDasharray=\"30 90\" className={own ? \"text-frame\" : \"text-fg\"} />\n            </motion.svg>\n          )}\n        </AnimatePresence>\n      </button>\n\n      <div className=\"flex min-w-0 flex-col gap-1\">\n        {/* The waveform is the seek bar: click or drag anywhere on it, or use the arrow keys. */}\n        <Slider.Root\n          value={time}\n          min={0}\n          max={Math.max(duration, 0.01)}\n          step={0.01}\n          largeStep={5}\n          disabled={!src || !duration || failed}\n          onValueChange={(v) => {\n            setScrubbing(true);\n            seek(v as number);\n          }}\n          onValueCommitted={() => setScrubbing(false)}\n          className=\"group/wave relative\"\n        >\n          <Slider.Control\n            ref={controlRef}\n            onPointerMove={(e) => {\n              const box = e.currentTarget.getBoundingClientRect();\n              e.currentTarget.style.setProperty(\"--hover\", `${Math.max(0, Math.min(1, (e.clientX - box.left) / box.width))}`);\n            }}\n            className={cn(\n              \"relative flex h-7 cursor-pointer touch-none items-center rounded-md outline-none\",\n              \"data-disabled:cursor-default\",\n              // A keyboard focus ring around the whole waveform, since the thumb itself is a hairline.\n              \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2\",\n              own ? \"has-[:focus-visible]:outline-frame/50\" : \"has-[:focus-visible]:outline-fg-3\",\n            )}\n            style={{ width: bars * 5 - 2 }}\n          >\n            <Waveform key={drawn ? \"drawn\" : \"flat\"} levels={levels} className=\"opacity-35\" animateIn={drawn && !peaksProp && !reduce} />\n            {/* Where the pointer is, a lighter preview of the fill, so you can aim before you click. */}\n            <Waveform\n              levels={levels}\n              className=\"absolute inset-0 opacity-0 transition-opacity duration-150 pointer-fine:group-hover/wave:opacity-60 group-data-dragging/wave:opacity-0!\"\n              style={{ clipPath: \"inset(0 calc((1 - var(--hover, 0)) * 100%) 0 0)\" }}\n            />\n            <Waveform levels={levels} className=\"absolute inset-0\" style={{ clipPath: `inset(0 ${(1 - progress) * 100}% 0 0)` }} />\n            <Slider.Track className=\"absolute inset-0\" style={{ position: \"absolute\", inset: 0 }}>\n              <Slider.Thumb\n                aria-label={`Seek ${label.toLowerCase()}`}\n                getAriaValueText={() => `${spoken(time)} of ${spoken(duration)}`}\n                onKeyDown={(e) => {\n                  // Arrows step a second at a time; Space plays and pauses without leaving the waveform.\n                  if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n                    e.preventDefault();\n                    seek(time + (e.key === \"ArrowRight\" ? 1 : -1) * (e.shiftKey ? 5 : 1));\n                  } else if (e.key === \" \") {\n                    e.preventDefault();\n                    toggle();\n                  }\n                }}\n                className={cn(\n                  \"top-1/2 h-5 w-[3px] rounded-full outline-none\",\n                  own ? \"bg-frame\" : \"bg-fg\",\n                  \"opacity-0 transition-[opacity,scale] duration-150 ease-out\",\n                  \"group-hover/wave:opacity-100 has-[:focus-visible]:opacity-100 data-dragging:scale-y-110 data-dragging:opacity-100\",\n                  \"motion-reduce:transition-none\",\n                )}\n              />\n            </Slider.Track>\n          </Slider.Control>\n        </Slider.Root>\n\n        <div className=\"flex h-5 items-center justify-between gap-3\">\n          <span className={cn(\"flex items-center gap-1.5 text-[11px] leading-4 tabular\", own ? \"text-frame/70\" : \"text-fg-3\", failed && (own ? \"text-frame\" : \"text-danger\"))}>\n            <span aria-hidden>{failed ? \"Couldn’t play. Tap to retry\" : clock(shownTime)}</span>\n            <AnimatePresence initial={false}>\n              {!played && !failed && (\n                <motion.span\n                  key=\"dot\"\n                  aria-hidden\n                  className={cn(\"size-1.5 rounded-full\", own ? \"bg-frame\" : \"bg-fg\")}\n                  initial={{ opacity: 0, scale: 0.4 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.2, transition: { duration: 0.2, ease: ease.in } }}\n                />\n              )}\n            </AnimatePresence>\n            <span className=\"sr-only\">{`${spoken(duration)}${played ? \"\" : \", not played yet\"}`}</span>\n          </span>\n\n          <button\n            type=\"button\"\n            onClick={cycleRate}\n            aria-label={`Playback speed ${rate}×. Change speed`}\n            className={cn(\n              \"relative grid h-5 min-w-8 place-items-center rounded-full px-1.5 text-[10.5px] font-medium leading-none tabular outline-none\",\n              \"transition-[background-color,scale] duration-150 ease-out active:scale-[0.92] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1\",\n              own ? \"bg-frame/15 hover:bg-frame/25 focus-visible:outline-frame/60\" : \"bg-fg/[0.08] hover:bg-fg/[0.14] focus-visible:outline-fg-3\",\n              \"before:absolute before:-inset-x-1.5 before:-inset-y-3 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            {/* Every rate sits in one cell so the pill never changes width. */}\n            <span className=\"grid overflow-hidden\">\n              {RATES.map((r) => (\n                <span key={r} aria-hidden className=\"invisible col-start-1 row-start-1\">\n                  {r}×\n                </span>\n              ))}\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={rate}\n                  aria-hidden\n                  className=\"col-start-1 row-start-1 text-center\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, transition: { duration: 0.1 } }}\n                  transition={reduce ? { duration: 0.12 } : spring.snappy}\n                >\n                  {rate}×\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction Waveform({ levels, className, style, animateIn = false }: { levels: number[]; className?: string; style?: React.CSSProperties; animateIn?: boolean }) {\n  return (\n    <span aria-hidden className={cn(\"pointer-events-none flex h-full w-full items-center gap-[2px]\", className)} style={style}>\n      {levels.map((l, i) => (\n        <motion.span\n          key={i}\n          className=\"w-[3px] shrink-0 rounded-full bg-current\"\n          style={{ height: `${Math.round(4 + l * 20)}px` }}\n          // When peaks arrive after decoding, the bars grow into place from a flat line, left to right.\n          initial={animateIn ? { scaleY: 0.25 } : false}\n          animate={{ scaleY: 1 }}\n          transition={{ duration: 0.24, ease: ease.out, delay: animateIn ? Math.min(i, 30) * 0.008 : 0 }}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/voice-message"
}
