{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-audio",
  "title": "Audio message",
  "description": "A voice message bubble with a waveform, play control, and a seekable position.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/message-bubble.json",
    "https://imessage.swerdlow.dev/r/platform.json",
    "https://imessage.swerdlow.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/imessage/message-audio.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type SetStateAction } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/message-ui/platform\";\nimport { bubbleMetrics, fontStack, type Direction } from \"@/components/message-ui/tokens\";\nimport { MessageBubble } from \"@/components/message-ui/message-bubble\";\n\n/**\n * An audio message: a waveform, a play control, and the remaining time, inside a normal bubble.\n *\n * No native capture of an audio message exists in `references/`, but ChatKit describes the row and\n * this now uses what it says instead of the shape that was guessed here:\n *\n * | value | ChatKit | what was here |\n * |---|---|---|\n * | waveform height | `audioWaveformHeight` **35** | 28 (shared with the button) |\n * | bar gap | `audioWaveformGapWidth` **2** | 2.6 |\n * | play control | `audioProgressViewSize` **{29, 29}** | 28 |\n * | control to waveform | `audioBalloonHorizontalSpacing` **10** | 10, right by luck |\n * | waveform to time | `audioBalloonWaveformTimeSpace` **6** | 10 |\n * | vertical inset | `audioBalloonVerticalSpacing` **7** | the text bubble's 10 |\n *\n * Read at both idioms rather than assumed: `CKUIBehaviorMac` overrides exactly one of them,\n * `audioProgressViewSize` **{22, 22}**. Everything else above is the same on the Mac, including the\n * 35 waveform, which is why macOS no longer draws a 22 one.\n *\n * So the row is not one height: the waveform is 35 and the control is 29, and the balloon insets its\n * contents by 7 vertically rather than by the 10 a text bubble uses. `audioBalloonAlignmentInsets`\n * is {0,0,0,0}, which is what says the 7 is the whole vertical inset.\n *\n * Still not measured, because ChatKit is silent on them: the **bar width**, and therefore the bar\n * count, which is only how many fit. `audioRecordingViewTimeBetweenWaveformSegments` is 1/12 s, so a\n * recording lays down 12 bars a second - that fixes the count for a recording of known length but\n * not the width of one.\n *\n * What is not guessed:\n * - The bubble, its radius and its tail are the measured ones, because this renders inside\n *   `MessageBubble`.\n * - The duration label reuses a type size measured on its own platform: iOS 13pt (the \"Send with\n *   effect\" segmented-control label, the one measured step between the 11pt secondary label and the\n *   17pt body) and macOS 11pt (the attachment card's \"Text Document / 275 bytes\" line).\n * - The row's width is the sum of its parts, so the bubble hugs it the way a text bubble hugs its\n *   longest line. `maxWidth` is passed in px: the platform default is a percentage of the bubble's\n *   own shrink-to-fit box, which collapses for content that is not text.\n * - The bubble's line-hugging fit measures the laid-out line boxes, so the row has to be the widest\n *   of them. It is: one inline box holding everything, with no loose text beside it. The duration\n *   still contributes a second, narrower rect of its own, which the fit ignores.\n */\nexport type MessageAudioProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Bar heights, 0..1, oldest first. Pass real peaks, or leave it out for a deterministic stand-in. */\n  peaks?: number[];\n  /** Total length in seconds. */\n  duration: number;\n  direction?: Direction;\n  tail?: boolean;\n  /** Controlled playback position in seconds. */\n  position?: number;\n  playing?: boolean;\n  onPlayChange?: (playing: boolean) => void;\n  onSeek?: (seconds: number) => void;\n  /** Widest the bubble may grow, in px. Defaults to the platform's maximum bubble width. */\n  maxWidth?: number;\n  platform?: Platform;\n};\n\nfunction fallbackPeaks(count: number): number[] {\n  // Deterministic, so screenshots are stable and no Math.random leaks into a render.\n  return Array.from({ length: count }, (_, i) => {\n    const a = Math.sin(i * 0.7) * 0.5 + 0.5;\n    const b = Math.sin(i * 1.9 + 1.1) * 0.5 + 0.5;\n    return 0.18 + Math.min(1, a * 0.6 + b * 0.55) * 0.82;\n  });\n}\n\nfunction clock(seconds: number): string {\n  const whole = Math.max(0, Math.round(seconds));\n  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, \"0\")}`;\n}\n\nexport function MessageAudio({ peaks, duration, direction = \"outgoing\", tail = false, position = 0, playing = false, onPlayChange, onSeek, maxWidth, platform: platformProp, className, style, ...props }: MessageAudioProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const ios = platform === \"ios\";\n  // The bar count is still only how many fit, because ChatKit gives a gap and no bar width. At the\n  // measured maximum bubble width: iOS 280.5 - 27.7 padding - 29 control - 16 of gaps - 28.13 for a\n  // \"0:17\" label leaves 179.67, and 36 bars at 3 with 2 between them are 178 of it.\n  const barCount = ios ? 36 : 28;\n  const bars = useMemo(() => (peaks?.length ? peaks : fallbackPeaks(barCount)), [peaks, barCount]);\n  // UNVERIFIED: no source gives a bar width.\n  const barWidth = ios ? 3 : 2.5;\n  /** `audioWaveformGapWidth`. */\n  const barGap = ios ? 2 : 2;\n  /** `audioWaveformHeight`: 35 on both idioms, taller than the control beside it. */\n  const waveHeight = 35;\n  /** `audioProgressViewSize`: the one value the Mac class overrides, {22, 22} against {29, 29}. */\n  const controlSize = ios ? 29 : 22;\n  /** `audioBalloonVerticalSpacing`: 7 on both, not the text bubble's 10 (iOS) or 7.03 (macOS). */\n  const insetY = 7;\n  /** `audioBalloonWaveformTimeSpace`: 6 on both. */\n  const timeGap = 6;\n  const played = duration > 0 ? Math.max(0, Math.min(1, position / duration)) : 0;\n  const track = useRef<HTMLSpanElement>(null);\n  const outgoing = direction === \"outgoing\";\n  const ink = outgoing ? \"var(--im-outgoing-text, #ffffff)\" : \"var(--im-incoming-text)\";\n\n  const seek = (clientX: number) => {\n    const element = track.current;\n    if (!element || !onSeek) return;\n    const rect = element.getBoundingClientRect();\n    onSeek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);\n  };\n\n  return (\n    <MessageBubble data-slot=\"message-audio\" direction={direction} tail={tail} platform={platform}\n      maxWidth={maxWidth ?? bubbleMetrics[platform].maxWidth}\n      className={cn(className)} style={{ fontFamily: fontStack, ...style }} {...props}>\n      {/* One inline-flex box, so the bubble's text fit sees the whole row as the widest line and hugs\n          it. The name is an aria-label rather than visually hidden text: text inside the bubble lays\n          out its own line box, which the fit would then measure instead of this row. `max-width` is\n          what keeps an inline box, which otherwise overflows rather than wrapping, inside the\n          measured padding; a long duration then narrows the bars instead. */}\n      <span role=\"group\" aria-label={`Audio message, ${clock(duration)}`}\n        className=\"inline-flex items-center align-middle\"\n        style={{ gap: ios ? 10 : 8, maxWidth: \"100%\", whiteSpace: \"nowrap\", marginBlock: ios ? insetY - bubbleMetrics[platform].paddingY : 0 }}>\n        <button type=\"button\" aria-label={playing ? \"Pause audio message\" : \"Play audio message\"} aria-pressed={playing}\n          onClick={() => onPlayChange?.(!playing)}\n          className=\"flex shrink-0 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ width: controlSize, height: controlSize, background: outgoing ? \"rgba(255,255,255,0.22)\" : \"rgba(0,0,0,0.08)\", color: ink }}>\n          {playing ? (\n            <svg aria-hidden=\"true\" width={ios ? 11 : 9} height={ios ? 12 : 10} viewBox=\"0 0 11 12\"><rect x=\"0\" y=\"0\" width=\"4\" height=\"12\" rx=\"1.2\" fill=\"currentColor\" /><rect x=\"7\" y=\"0\" width=\"4\" height=\"12\" rx=\"1.2\" fill=\"currentColor\" /></svg>\n          ) : (\n            <svg aria-hidden=\"true\" width={ios ? 11 : 9} height={ios ? 12 : 10} viewBox=\"0 0 11 12\"><path d=\"M1 1.1c0-.8.9-1.3 1.6-.9l7 4.9c.6.4.6 1.4 0 1.8l-7 4.9c-.7.4-1.6-.1-1.6-.9z\" fill=\"currentColor\" /></svg>\n          )}\n        </button>\n        <span ref={track} data-slot=\"waveform\" role=\"slider\" tabIndex={0}\n          aria-label=\"Playback position\" aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={Math.round(position)} aria-valuetext={clock(position)}\n          onPointerDown={event => { event.currentTarget.setPointerCapture(event.pointerId); seek(event.clientX); }}\n          onPointerMove={event => { if (event.buttons === 1) seek(event.clientX); }}\n          onKeyDown={event => {\n            if (!onSeek) return;\n            if (event.key === \"ArrowRight\") { event.preventDefault(); onSeek(Math.min(duration, position + 1)); }\n            if (event.key === \"ArrowLeft\") { event.preventDefault(); onSeek(Math.max(0, position - 1)); }\n          }}\n          className=\"inline-flex flex-1 cursor-default items-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ height: waveHeight, gap: barGap }}>\n          {bars.map((peak, index) => {\n            const reached = index / bars.length <= played;\n            return (\n              <span key={index} aria-hidden=\"true\" style={{\n                width: barWidth, height: Math.max(3, peak * waveHeight), borderRadius: barWidth / 2,\n                background: ink, opacity: reached ? 1 : outgoing ? 0.45 : 0.28,\n              }} />\n            );\n          })}\n        </span>\n        {/* `audioBalloonWaveformTimeSpace`: the waveform and the time sit closer than the control and\n            the waveform do, so this gap is its own, not the row's. */}\n        <span aria-hidden=\"true\" style={{ marginInlineStart: timeGap - (ios ? 10 : 8), fontSize: ios ? 13 : 11, color: ink, opacity: 0.75, fontVariantNumeric: \"tabular-nums\" }}>\n          {clock(playing || position > 0 ? duration - position : duration)}\n        </span>\n      </span>\n    </MessageBubble>\n  );\n}\n\n/** Drives `position` while `playing`, without pulling in an audio element. */\nexport function useAudioProgress(duration: number, playing: boolean) {\n  const [position, setPosition] = useState(0);\n  const positionRef = useRef(0);\n  const [seekVersion, setSeekVersion] = useState(0);\n  const seek = useCallback((value: SetStateAction<number>) => {\n    const next = typeof value === \"function\" ? value(positionRef.current) : value;\n    positionRef.current = Math.max(0, Math.min(duration, next));\n    setPosition(positionRef.current);\n    // Restart the clock from the seek position, including after reaching the end.\n    setSeekVersion(version => version + 1);\n  }, [duration]);\n  useEffect(() => {\n    if (!playing) return;\n    let raf = 0;\n    let started: number | undefined;\n    const base = positionRef.current;\n    const step = (now: number) => {\n      started ??= now;\n      const next = Math.min(duration, base + (now - started) / 1000);\n      positionRef.current = next;\n      setPosition(next);\n      if (next < duration) raf = requestAnimationFrame(step);\n    };\n    raf = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(raf);\n  }, [playing, duration, seekVersion]);\n  return [position, seek] as const;\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/message-audio.tsx"
    }
  ],
  "type": "registry:ui"
}
