{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-note",
  "type": "registry:component",
  "title": "Voice Note",
  "description": "A chat voice message you can scrub like a waveform, with a play button that morphs to pause, a speed chip that rolls, and a transcript that follows along word by word.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/voice-note.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ChevronDownIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type PointerEvent, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Stays false until mounted so the server render and the first client render match.\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface VoiceNoteWord {\n  text: string;\n  start: number;\n}\n\nexport interface VoiceNoteSender {\n  name: string;\n  initials: string;\n  avatarClassName?: string;\n}\n\nexport interface VoiceNoteProps {\n  sender?: VoiceNoteSender;\n  sentAt?: string;\n  duration?: number;\n  src?: string;\n  peaks?: number[];\n  transcript?: VoiceNoteWord[];\n  speeds?: number[];\n  defaultTranscriptOpen?: boolean;\n  unplayed?: boolean;\n  className?: string;\n}\n\nconst defaultTranscript: VoiceNoteWord[] = (() => {\n  const text =\n    \"Hey, quick one before standup. The venue moved us to the upstairs room, so the projector is sorted. Can you bring the clicker and two extension cords? I'll grab coffee for everyone on the way in. See you at ten!\";\n  let t = 0.35;\n  return text.split(\" \").map((word) => {\n    const start = t;\n    t += 0.18 + word.length * 0.045 + (/[.,?!]$/.test(word) ? 0.32 : 0);\n    return { text: word, start: Math.round(start * 100) / 100 };\n  });\n})();\n\nfunction seededPeaks(count: number, seed = 7) {\n  let s = seed;\n  const rand = () => {\n    s = (s * 16807) % 2147483647;\n    return s / 2147483647;\n  };\n  return Array.from({ length: count }, (_, i) => {\n    const phrase = 0.55 + 0.45 * Math.sin(i / 3.1) * Math.sin(i / 7.3 + 1);\n    const v = 0.22 + Math.abs(phrase) * 0.6 + rand() * 0.28;\n    return Math.min(1, Math.max(0.12, v));\n  });\n}\n\nfunction formatTime(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n}\n\n// Both icons use the same point count so the paths morph cleanly.\nconst ICON = {\n  play: [\"M8 5 L13.5 8.5 L13.5 15.5 L8 19 Z\", \"M13.5 8.5 L19 12 L19 12 L13.5 15.5 Z\"],\n  pause: [\"M6.5 5 L10 5 L10 19 L6.5 19 Z\", \"M14 5 L17.5 5 L17.5 19 L14 19 Z\"],\n};\n\nfunction PlayPauseIcon({ playing, reduce }: { playing: boolean; reduce: boolean | null }) {\n  const paths = playing ? ICON.pause : ICON.play;\n  const transition = reduce ? { duration: 0 } : { duration: 0.18, ease: [0.23, 1, 0.32, 1] as const };\n  return (\n    <svg aria-hidden className=\"size-4\" fill=\"currentColor\" stroke=\"currentColor\" strokeLinejoin=\"round\" strokeWidth={1.5} viewBox=\"0 0 24 24\">\n      <motion.path animate={{ d: paths[0] }} initial={false} transition={transition} />\n      <motion.path animate={{ d: paths[1] }} initial={false} transition={transition} />\n    </svg>\n  );\n}\n\nexport function VoiceNote({\n  sender = {\n    name: \"Noor Haddad\",\n    initials: \"NH\",\n    avatarClassName: \"bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-300\",\n  },\n  sentAt = \"9:41\",\n  duration: durationProp,\n  src,\n  peaks: peaksProp,\n  transcript = defaultTranscript,\n  speeds = [1, 1.5, 2],\n  defaultTranscriptOpen = true,\n  unplayed = true,\n  className,\n}: VoiceNoteProps) {\n  const reduce = useSafeReducedMotion();\n  const transcriptId = useId();\n  const audioRef = useRef<HTMLAudioElement>(null);\n  const waveRef = useRef<HTMLDivElement>(null);\n\n  const fallbackDuration = useMemo(() => {\n    const last = transcript[transcript.length - 1];\n    return last ? Math.ceil(last.start + 1.2) : 20;\n  }, [transcript]);\n  const [mediaDuration, setMediaDuration] = useState<number | null>(null);\n  const duration = mediaDuration ?? durationProp ?? fallbackDuration;\n  const peaks = useMemo(() => peaksProp ?? seededPeaks(44), [peaksProp]);\n\n  const [time, setTime] = useState(0);\n  const [playing, setPlaying] = useState(false);\n  const [speedIndex, setSpeedIndex] = useState(0);\n  const [scrubbing, setScrubbing] = useState(false);\n  const [played, setPlayed] = useState(!unplayed);\n  const [open, setOpen] = useState(defaultTranscriptOpen);\n  const speed = speeds[speedIndex] ?? 1;\n\n  const timeRef = useRef(0);\n  timeRef.current = time;\n\n  useEffect(() => {\n    if (!playing || scrubbing) return;\n    const audio = audioRef.current;\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const dt = (now - last) / 1000;\n      last = now;\n      const next = audio && src ? audio.currentTime : timeRef.current + dt * speed;\n      if (next >= duration) {\n        setTime(0);\n        setPlaying(false);\n        if (audio && src) {\n          audio.pause();\n          audio.currentTime = 0;\n        }\n        return;\n      }\n      setTime(next);\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, scrubbing, speed, duration, src]);\n\n  useEffect(() => {\n    const audio = audioRef.current;\n    if (!audio || !src) return;\n    audio.playbackRate = speed;\n  }, [speed, src]);\n\n  const toggle = useCallback(() => {\n    const audio = audioRef.current;\n    setPlayed(true);\n    setPlaying((p) => {\n      if (audio && src) {\n        if (p) audio.pause();\n        else void audio.play().catch(() => setPlaying(false));\n      }\n      return !p;\n    });\n  }, [src]);\n\n  const seek = useCallback(\n    (to: number) => {\n      const t = Math.min(duration - 0.05, Math.max(0, to));\n      setTime(t);\n      setPlayed(true);\n      const audio = audioRef.current;\n      if (audio && src) audio.currentTime = t;\n    },\n    [duration, src],\n  );\n\n  const timeFromPointer = (clientX: number) => {\n    const rect = waveRef.current?.getBoundingClientRect();\n    if (!rect) return 0;\n    return ((clientX - rect.left) / rect.width) * duration;\n  };\n\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    setScrubbing(true);\n    seek(timeFromPointer(e.clientX));\n  };\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    if (scrubbing) seek(timeFromPointer(e.clientX));\n  };\n  const endScrub = () => setScrubbing(false);\n\n  const onSliderKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const step = e.shiftKey ? 5 : 1;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") seek(time + step);\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") seek(time - step);\n    else if (e.key === \"Home\") seek(0);\n    else if (e.key === \"End\") seek(duration);\n    else if (e.key === \" \" || e.key === \"Enter\") toggle();\n    else return;\n    e.preventDefault();\n  };\n\n  const progress = Math.min(1, time / duration);\n  const showHead = scrubbing || time > 0;\n\n  let activeWord = -1;\n  if (time > 0 || playing) {\n    for (let i = 0; i < transcript.length; i++) {\n      if (transcript[i].start <= time) activeWord = i;\n      else break;\n    }\n  }\n\n  return (\n    <article\n      aria-label={`Voice message from ${sender.name}, ${formatTime(duration)}`}\n      className={cn(\"flex w-full max-w-[380px] items-end gap-2.5\", className)}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"mb-0.5 flex size-8 shrink-0 select-none items-center justify-center rounded-full font-medium text-xs\",\n          sender.avatarClassName ?? \"bg-muted text-foreground\",\n        )}\n      >\n        {sender.initials}\n      </span>\n\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"mb-1 flex items-baseline gap-2 ps-1\">\n          <span className=\"truncate font-medium text-xs\">{sender.name}</span>\n          <span className=\"text-muted-foreground text-xs tabular-nums\">{sentAt}</span>\n        </div>\n\n        <div className=\"overflow-hidden rounded-2xl rounded-bl-md border bg-card text-card-foreground shadow-xs/5\">\n          {src && (\n            <audio\n              onLoadedMetadata={(e) => Number.isFinite(e.currentTarget.duration) && setMediaDuration(e.currentTarget.duration)}\n              preload=\"metadata\"\n              ref={audioRef}\n              src={src}\n            />\n          )}\n\n          <div className=\"flex items-center gap-3 p-3 pe-2.5\">\n            <button\n              aria-label={playing ? \"Pause\" : \"Play voice message\"}\n              className=\"flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-transform duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-[0.97]\"\n              onClick={toggle}\n              type=\"button\"\n            >\n              <PlayPauseIcon playing={playing} reduce={reduce} />\n            </button>\n\n            <div className=\"min-w-0 flex-1\">\n              <div\n                aria-label=\"Seek\"\n                aria-valuemax={Math.round(duration)}\n                aria-valuemin={0}\n                aria-valuenow={Math.round(time)}\n                aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}\n                className=\"group/wave relative h-8 cursor-pointer touch-none rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\"\n                onKeyDown={onSliderKey}\n                onLostPointerCapture={endScrub}\n                onPointerCancel={endScrub}\n                onPointerDown={onPointerDown}\n                onPointerMove={onPointerMove}\n                onPointerUp={endScrub}\n                ref={waveRef}\n                role=\"slider\"\n                tabIndex={0}\n              >\n                <Bars className=\"text-muted-foreground/35\" peaks={peaks} />\n                <div\n                  aria-hidden\n                  className=\"absolute inset-0\"\n                  style={{ clipPath: `inset(0 ${(1 - progress) * 100}% 0 0)` }}\n                >\n                  <Bars className=\"text-foreground\" peaks={peaks} />\n                </div>\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground shadow-[0_0_0_2px_var(--color-card)] transition-[opacity,scale] duration-150 ease-out\",\n                    showHead ? \"opacity-100\" : \"scale-75 opacity-0 group-hover/wave:scale-100 group-hover/wave:opacity-100\",\n                    scrubbing && \"scale-125\",\n                  )}\n                  style={{ left: `${progress * 100}%` }}\n                />\n              </div>\n              <div className=\"mt-1 flex items-center gap-1.5 text-muted-foreground text-xs tabular-nums\">\n                <span>{formatTime(time > 0 || playing ? time : duration)}</span>\n                <AnimatePresence initial={false}>\n                  {!played && (\n                    <motion.span\n                      aria-label=\"Not played yet\"\n                      className=\"size-1.5 rounded-full bg-info\"\n                      exit={{ opacity: 0, scale: 0.6 }}\n                      role=\"img\"\n                      transition={{ duration: 0.15 }}\n                    />\n                  )}\n                </AnimatePresence>\n              </div>\n            </div>\n\n            <button\n              aria-label={`Playback speed ${speed}×, change`}\n              className=\"relative mt-1 flex h-6 w-11 shrink-0 cursor-pointer items-center justify-center self-start rounded-full bg-muted font-medium text-xs tabular-nums outline-none transition-[background-color,scale] duration-150 ease-out before:absolute before:-inset-x-1 before:-inset-y-2 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n              onClick={() => setSpeedIndex((i) => (i + 1) % speeds.length)}\n              type=\"button\"\n            >\n              <span className=\"relative flex h-full w-full items-center justify-center overflow-hidden rounded-full\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ y: 0, opacity: 1 }}\n                  exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0 }}\n                  initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0 }}\n                  key={speed}\n                  transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}\n                >\n                  {speed}×\n                </motion.span>\n              </AnimatePresence>\n              </span>\n            </button>\n          </div>\n\n          {transcript.length > 0 && (\n            <div className=\"border-t\">\n              <button\n                aria-controls={transcriptId}\n                aria-expanded={open}\n                className=\"flex w-full cursor-pointer items-center gap-1.5 px-3 py-2 text-left text-muted-foreground text-xs outline-none transition-colors focus-visible:bg-accent [@media(hover:hover)]:hover:text-foreground\"\n                onClick={() => setOpen((o) => !o)}\n                type=\"button\"\n              >\n                <span className=\"font-medium text-foreground\">Transcript</span>\n                <span>· auto-generated</span>\n                <motion.span\n                  animate={{ rotate: open ? 180 : 0 }}\n                  className=\"ms-auto flex\"\n                  transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 36 }}\n                >\n                  <ChevronDownIcon className=\"size-3.5\" />\n                </motion.span>\n              </button>\n              <AnimatePresence initial={false}>\n                {open && (\n                  <motion.div\n                    animate={{ height: \"auto\", opacity: 1 }}\n                    exit={{ height: 0, opacity: 0 }}\n                    id={transcriptId}\n                    initial={{ height: 0, opacity: 0 }}\n                    transition={\n                      reduce\n                        ? { duration: 0 }\n                        : { type: \"spring\", stiffness: 420, damping: 38, opacity: { duration: 0.15 } }\n                    }\n                  >\n                    <Transcript activeWord={activeWord} instant={scrubbing} onSeek={seek} reduce={reduce} words={transcript} />\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          )}\n        </div>\n      </div>\n    </article>\n  );\n}\n\nfunction Bars({ peaks, className }: { peaks: number[]; className?: string }) {\n  return (\n    <div aria-hidden className={cn(\"absolute inset-0 flex items-center justify-between\", className)}>\n      {peaks.map((p, i) => (\n        <span\n          className=\"w-[3px] shrink-0 rounded-full bg-current\"\n          key={i}\n          style={{ height: `${Math.round(p * 100)}%`, minHeight: 3 }}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction Transcript({\n  words,\n  activeWord,\n  onSeek,\n  reduce,\n  instant,\n}: {\n  words: VoiceNoteWord[];\n  activeWord: number;\n  instant?: boolean;\n  onSeek: (t: number) => void;\n  reduce: boolean | null;\n}) {\n  const wrapRef = useRef<HTMLParagraphElement>(null);\n  const [box, setBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null);\n  const lastY = useRef<number | null>(null);\n  const [jump, setJump] = useState(false);\n\n  useLayoutEffect(() => {\n    const el = wrapRef.current?.querySelector<HTMLElement>(`[data-word=\"${activeWord}\"]`);\n    if (!el) {\n      setBox(null);\n      lastY.current = null;\n      return;\n    }\n    const next = { x: el.offsetLeft - 3, y: el.offsetTop, w: el.offsetWidth + 6, h: el.offsetHeight };\n    setJump(lastY.current === null || lastY.current !== next.y);\n    lastY.current = next.y;\n    setBox(next);\n  }, [activeWord]);\n\n  return (\n    <p className=\"relative px-3 pb-3 text-pretty text-sm leading-relaxed\" ref={wrapRef}>\n      <AnimatePresence>\n        {box && (\n          <motion.span\n            animate={{ x: box.x, y: box.y, width: box.w, height: box.h, opacity: 1 }}\n            aria-hidden\n            className=\"pointer-events-none absolute top-0 left-0 rounded-md bg-info/14\"\n            exit={{ opacity: 0 }}\n            initial={{ x: box.x, y: box.y, width: box.w, height: box.h, opacity: 0 }}\n            transition={\n              reduce || jump || instant\n                ? { duration: 0, opacity: { duration: 0.12 } }\n                : { type: \"spring\", stiffness: 700, damping: 48, opacity: { duration: 0.12 } }\n            }\n          />\n        )}\n      </AnimatePresence>\n      {words.map((w, i) => (\n        <span key={`${i}-${w.text}`}>\n          <span\n            className={cn(\n              \"relative cursor-pointer rounded-sm transition-colors duration-150\",\n              i <= activeWord ? \"text-foreground\" : \"text-muted-foreground\",\n              i === activeWord && \"text-info-foreground\",\n            )}\n            data-word={i}\n            onClick={() => onSeek(w.start + 0.01)}\n          >\n            {w.text}\n          </span>{\" \"}\n        </span>\n      ))}\n    </p>\n  );\n}\n"
    }
  ]
}