{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-note",
  "title": "Voice Note",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Nota de voz de chat com forma de onda, play e pausa, e busca clicando ou arrastando na onda.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/voice-note.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { Pause, Play, X } from \"lucide-react\";\n\nimport { DUR, EASE_OUT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Voice Note: a nota de voz de chat, com forma de onda, play/pause e progresso.\n *\n * A forma de onda é o que faz um áudio parecer conteúdo e não anexo: dá pra ver\n * onde a pessoa falou e onde respirou, e clicar direto no trecho. Esta peça é a\n * casca de reprodução: recebe os picos (`peaks`) e a duração, e anda um relógio\n * próprio. Não grava nem toca som: quem tem o áudio liga `onPlayingChange` e\n * `onSeek` no player real, ou usa como está pra protótipo e demo.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * 1. **A onda é desenhada duas vezes.** Uma camada apagada, outra cheia por\n *    cima, recortada por `clip-path` no ponto do progresso. O corte pode cair\n *    no meio de uma barra, como no áudio de verdade, e nada renderiza por\n *    quadro: o progresso é um `MotionValue`.\n * 2. **O relógio na tela troca uma vez por segundo**, não por quadro. Mostra o\n *    tempo corrido enquanto toca ou está pausado no meio, e a duração total\n *    quando está parado no início, que é o que se quer saber antes de tocar.\n * 3. **A onda é um `slider`.** Clique e arrasto buscam; com foco, setas andam\n *    1 s, Home/End vão às pontas, Espaço toca e pausa. O tempo falado é\n *    \"0:02 de 0:07\", não um número solto.\n * 4. **Acabou, volta pro começo.** O botão vira play de novo e o próximo\n *    toque recomeça, sem a pessoa precisar arrastar de volta.\n */\n\nexport interface VoiceNoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onAnimationStart\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationEnd\"> {\n  /** Picos da onda, de 0 a 1. Cada um vira uma barra. */\n  peaks?: number[];\n  /** Duração em segundos. */\n  duration?: number;\n  /** Cor do botão e da onda tocada. */\n  tone?: \"neutral\" | \"brand\";\n  /** Mostra o X e chama isto ao apagar. */\n  onDelete?: () => void;\n  /** Avisa quando toca e pausa (pra ligar num player de verdade). */\n  onPlayingChange?: (playing: boolean) => void;\n  /** Avisa a posição buscada, em segundos. */\n  onSeek?: (seconds: number) => void;\n  /** Nome da nota pra leitor de tela. */\n  label?: string;\n}\n\n/** Onda de demo com cara de fala: frases com pausa entre elas. Determinística, pra não piscar no SSR. */\nfunction ondaDeDemo(n = 36) {\n  return Array.from({ length: n }, (_, i) => {\n    const frase = Math.abs(Math.sin(i / 3.1));\n    const grao = (Math.sin(i * 12.9898) * 43758.5453) % 1;\n    return Math.max(0.08, Math.min(1, frase * 0.75 + Math.abs(grao) * 0.35));\n  });\n}\n\nconst DEMO = ondaDeDemo();\n\nfunction relogio(s: number) {\n  const t = Math.max(0, Math.floor(s));\n  return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, \"0\")}`;\n}\n\nconst TOM = {\n  neutral: { botao: \"bg-foreground text-background\", onda: \"bg-foreground\" },\n  brand: { botao: \"bg-[var(--brand)] text-[var(--brand-foreground)]\", onda: \"bg-[var(--brand)]\" },\n} as const;\n\nfunction Barras({ peaks, cor }: { peaks: number[]; cor: string }) {\n  return (\n    <span className=\"flex h-full w-full items-center justify-between gap-[2px]\">\n      {peaks.map((p, i) => (\n        <span\n          key={i}\n          className={cn(\"w-[2px] flex-none rounded-[var(--r-pill)]\", cor)}\n          style={{ height: `${Math.round(12 + Math.min(1, Math.max(0, p)) * 88)}%` }}\n        />\n      ))}\n    </span>\n  );\n}\n\nexport function VoiceNote({\n  peaks = DEMO,\n  duration = 7,\n  tone = \"neutral\",\n  onDelete,\n  onPlayingChange,\n  onSeek,\n  label = \"Nota de voz\",\n  className,\n  ...props\n}: VoiceNoteProps) {\n  const reduzido = useReducedMotion() ?? false;\n  const tempo = useMotionValue(0);\n  const recorte = useTransform(tempo, (t) => `inset(0 ${(100 - (t / duration) * 100).toFixed(3)}% 0 0)`);\n  const [tocando, setTocando] = React.useState(false);\n  const [segundo, setSegundo] = React.useState(0);\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const arrasto = React.useRef<number | null>(null);\n  const tom = TOM[tone];\n\n  React.useEffect(() => tempo.on(\"change\", (t) => setSegundo(Math.floor(t))), [tempo]);\n  React.useEffect(() => () => anim.current?.stop(), []);\n\n  function tocar(de = tempo.get()) {\n    anim.current?.stop();\n    const inicio = de >= duration ? 0 : de;\n    tempo.jump(inicio);\n    // O relógio anda linear: é tempo de áudio, não gesto.\n    anim.current = animate(tempo, duration, {\n      duration: duration - inicio,\n      ease: \"linear\",\n      onComplete: () => {\n        setTocando(false);\n        onPlayingChange?.(false);\n        tempo.jump(0);\n      },\n    });\n    setTocando(true);\n    onPlayingChange?.(true);\n  }\n\n  function pausar() {\n    anim.current?.stop();\n    setTocando(false);\n    onPlayingChange?.(false);\n  }\n\n  function alternar() {\n    if (tocando) pausar();\n    else tocar();\n  }\n\n  function buscar(s: number) {\n    const alvo = Math.min(duration, Math.max(0, s));\n    if (tocando) tocar(alvo);\n    else tempo.jump(alvo);\n    onSeek?.(alvo);\n  }\n\n  function posicao(e: React.PointerEvent<HTMLDivElement>) {\n    // Razão entre dois retângulos de tela: a escala da página se cancela.\n    const r = e.currentTarget.getBoundingClientRect();\n    return r.width ? ((e.clientX - r.left) / r.width) * duration : 0;\n  }\n\n  const parado = !tocando && segundo === 0;\n\n  return (\n    <div\n      {...props}\n      role=\"group\"\n      aria-label={label}\n      className={cn(\n        \"inline-flex h-12 w-[280px] max-w-full items-center gap-3 rounded-[var(--r-pill)] border border-border bg-card pl-1.5 pr-3 text-card-foreground [box-shadow:var(--e-raised)]\",\n        className,\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={alternar}\n        aria-label={tocando ? \"Pausar\" : \"Tocar\"}\n        className={cn(\n          \"relative grid size-9 pointer-coarse:after:absolute pointer-coarse:after:-inset-1 flex-none place-items-center rounded-[var(--r-pill)] outline-none [font:inherit] focus-visible:[box-shadow:var(--focus)]\",\n          \"transition-[opacity] [transition-duration:var(--dur-fast)] [transition-timing-function:var(--ease-out)] hover:opacity-90 active:opacity-80\",\n          tom.botao,\n        )}\n      >\n        <span className=\"grid [&>*]:col-start-1 [&>*]:row-start-1\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={tocando ? \"pausa\" : \"play\"}\n              className=\"grid place-items-center\"\n              initial={reduzido ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduzido ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n              transition={{ duration: DUR.fast, ease: EASE_OUT }}\n            >\n              {tocando ? (\n                <Pause className=\"size-4\" fill=\"currentColor\" strokeWidth={0} />\n              ) : (\n                <Play className=\"ml-0.5 size-4\" fill=\"currentColor\" strokeWidth={0} />\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n\n      <div\n        role=\"slider\"\n        tabIndex={0}\n        aria-label=\"Posição\"\n        aria-valuemin={0}\n        aria-valuemax={Math.round(duration)}\n        aria-valuenow={segundo}\n        aria-valuetext={`${relogio(segundo)} de ${relogio(duration)}`}\n        onPointerDown={(e) => {\n          if (!e.isPrimary || e.button !== 0) return;\n          e.currentTarget.setPointerCapture(e.pointerId);\n          arrasto.current = e.pointerId;\n          buscar(posicao(e));\n        }}\n        onPointerMove={(e) => {\n          if (arrasto.current !== e.pointerId) return;\n          const s = Math.min(duration, Math.max(0, posicao(e)));\n          // Durante o arrasto o relógio segue o dedo; só volta a andar quando solta.\n          anim.current?.stop();\n          tempo.jump(s);\n        }}\n        onPointerUp={(e) => {\n          if (arrasto.current !== e.pointerId) return;\n          arrasto.current = null;\n          buscar(posicao(e));\n        }}\n        onPointerCancel={() => {\n          arrasto.current = null;\n        }}\n        onKeyDown={(e) => {\n          const t = tempo.get();\n          let alvo: number | null = null;\n          if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") alvo = t + 1;\n          else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") alvo = t - 1;\n          else if (e.key === \"Home\") alvo = 0;\n          else if (e.key === \"End\") alvo = duration;\n          else if (e.key === \" \" || e.key === \"Enter\") {\n            e.preventDefault();\n            alternar();\n            return;\n          }\n          if (alvo === null) return;\n          e.preventDefault();\n          buscar(alvo);\n        }}\n        className=\"relative h-7 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2 min-w-0 flex-1 cursor-pointer touch-pan-y rounded-[var(--r-control)] outline-none focus-visible:[box-shadow:var(--focus)]\"\n      >\n        <span aria-hidden=\"true\" className=\"absolute inset-0 opacity-30\">\n          <Barras peaks={peaks} cor=\"bg-muted-foreground\" />\n        </span>\n        <motion.span aria-hidden=\"true\" className=\"absolute inset-0\" style={{ clipPath: recorte }}>\n          <Barras peaks={peaks} cor={tom.onda} />\n        </motion.span>\n      </div>\n\n      <span className=\"w-9 flex-none text-right text-xs tabular-nums text-muted-foreground\" aria-hidden=\"true\">\n        {relogio(parado ? duration : segundo)}\n      </span>\n\n      {onDelete && (\n        <button\n          type=\"button\"\n          onClick={onDelete}\n          aria-label=\"Apagar nota de voz\"\n          className=\"relative -mr-1 grid size-7 pointer-coarse:after:absolute pointer-coarse:after:-inset-2 flex-none place-items-center rounded-[var(--r-pill)] text-muted-foreground outline-none [font:inherit] transition-[background-color,color] [transition-duration:var(--dur-fast)] [transition-timing-function:var(--ease-out)] hover:bg-muted hover:text-foreground focus-visible:[box-shadow:var(--focus)]\"\n        >\n          <X className=\"size-4\" strokeWidth={1.75} />\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport default VoiceNote;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Player de nota de voz em chat ou mensagem, com onda, play e busca arrastando",
      "evite": "Sinal de áudio ao vivo: vintage-oscilloscope; vídeo vertical, reels-rail"
    }
  },
  "categories": [
    "conteudo",
    "audio",
    "chat",
    "player",
    "forma-de-onda"
  ],
  "type": "registry:ui"
}