{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-player",
  "type": "registry:ui",
  "title": "Audio Player",
  "description": "Compact player whose waveform fills as it plays, with drag-to-seek and speed.",
  "categories": [
    "media"
  ],
  "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/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/audio-player.tsx",
      "content": "\"use client\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\n/* ------------------------------------------------------------------ waveform data */\n\n/**\n * Decodes audio and returns `count` loudness values between 0 and 1 (RMS per slice,\n * normalized to the 95th percentile so one spike doesn't flatten everything else).\n * Do this once on the server or at upload and store the numbers; this runs in the\n * browser too, for recordings that have none yet. Remote files need CORS.\n */\nexport async function computePeaks(source: string | Blob | ArrayBuffer, count = 64, signal?: AbortSignal): Promise<number[]> {\n  const buffer =\n    source instanceof ArrayBuffer ? source : source instanceof Blob ? await source.arrayBuffer() : await (await fetch(source, { signal })).arrayBuffer();\n  if (signal?.aborted) throw new DOMException(\"Aborted\", \"AbortError\");\n  // An offline context decodes without asking for the speakers.\n  const ctx = new OfflineAudioContext(1, 1, 44100);\n  const audio = await ctx.decodeAudioData(buffer);\n  const channels = Array.from({ length: audio.numberOfChannels }, (_, i) => audio.getChannelData(i));\n  const size = Math.max(1, Math.floor(audio.length / count));\n  const rms: number[] = [];\n  for (let b = 0; b < count; b++) {\n    let sum = 0;\n    const start = b * size;\n    const end = Math.min(audio.length, start + size);\n    for (let i = start; i < end; i++) {\n      let s = 0;\n      for (const ch of channels) s += ch[i];\n      s /= channels.length;\n      sum += s * s;\n    }\n    rms.push(Math.sqrt(sum / Math.max(1, end - start)));\n  }\n  const sorted = [...rms].sort((a, b) => a - b);\n  const ref = sorted[Math.floor(0.95 * (sorted.length - 1))] || 1;\n  return rms.map((v) => Math.min(1, Math.round((v / ref) * 100) / 100));\n}\n\n/** Fit any number of peaks to `count` bars: the loudest value wins when shrinking, neighbors blend when growing. */\nfunction resample(peaks: number[], count: number) {\n  if (!peaks.length) return Array.from({ length: count }, () => 0);\n  if (peaks.length === count) return peaks;\n  const out: number[] = [];\n  for (let i = 0; i < count; i++) {\n    if (peaks.length > count) {\n      const a = Math.floor((i * peaks.length) / count);\n      const b = Math.max(a + 1, Math.floor(((i + 1) * peaks.length) / count));\n      out.push(Math.max(...peaks.slice(a, b)));\n    } else {\n      const x = (i * (peaks.length - 1)) / Math.max(1, count - 1);\n      const lo = Math.floor(x);\n      const hi = Math.min(peaks.length - 1, lo + 1);\n      out.push(peaks[lo] + (peaks[hi] - peaks[lo]) * (x - lo));\n    }\n  }\n  return out;\n}\n\ntype PeaksStatus = \"ready\" | \"loading\" | \"unavailable\";\n\n// One decode per file per page, however many players (or effect re-runs) ask for it.\nconst decodes = new Map<string, Promise<number[]>>();\nconst decodeOnce = (src: string) => {\n  let p = decodes.get(src);\n  if (!p) {\n    p = computePeaks(src, 128);\n    decodes.set(src, p);\n    p.catch(() => decodes.delete(src));\n  }\n  return p;\n};\n\n/** Peaks you pass win; otherwise they're decoded from `src` once and shared. */\nexport function useWaveform(src: string, peaks: number[] | undefined, count: number) {\n  const [decoded, setDecoded] = useState<{ src: string; peaks: number[] | null } | null>(null);\n  useEffect(() => {\n    if (peaks) return;\n    let live = true;\n    decodeOnce(src).then(\n      (p) => live && setDecoded({ src, peaks: p }),\n      () => live && setDecoded({ src, peaks: null }),\n    );\n    return () => {\n      live = false;\n    };\n  }, [src, peaks]);\n\n  const mine = decoded?.src === src ? decoded : null;\n  const status: PeaksStatus = peaks ? \"ready\" : !mine ? \"loading\" : mine.peaks ? \"ready\" : \"unavailable\";\n  const values = useMemo(() => {\n    if (peaks) return resample(peaks, count);\n    if (mine?.peaks) return resample(mine.peaks, count);\n    // No data: a quiet, even line that still reads as \"audio\".\n    return Array.from({ length: count }, (_, i) => 0.22 + 0.08 * Math.sin(i * 1.7));\n  }, [peaks, mine, count]);\n  return { peaks: values, status, animateIn: !peaks && status === \"ready\" };\n}\n\n/* ------------------------------------------------------------------ media state */\n\nconst noop = () => {};\n\nfunction useMediaValue<T extends string | number | boolean>(el: HTMLMediaElement | null, events: string, read: (el: HTMLMediaElement) => T, fallback: T): T {\n  const subscribe = useCallback(\n    (cb: () => void) => {\n      if (!el) return noop;\n      const list = events.split(\" \");\n      list.forEach((e) => el.addEventListener(e, cb));\n      return () => list.forEach((e) => el.removeEventListener(e, cb));\n    },\n    [el, events],\n  );\n  return useSyncExternalStore(subscribe, () => (el ? read(el) : fallback), () => fallback);\n}\n\n/** currentTime every frame while playing, so the fill glides through each bar instead of jumping. */\nfunction useSmoothTime(el: HTMLMediaElement | null) {\n  const subscribe = useCallback(\n    (cb: () => void) => {\n      if (!el) return noop;\n      let raf = 0;\n      const loop = () => {\n        cb();\n        raf = requestAnimationFrame(loop);\n      };\n      const sync = () => {\n        cancelAnimationFrame(raf);\n        cb();\n        if (!el.paused && !el.ended) raf = requestAnimationFrame(loop);\n      };\n      const events = [\"play\", \"playing\", \"pause\", \"ended\", \"seeked\", \"timeupdate\", \"emptied\"];\n      events.forEach((e) => el.addEventListener(e, sync));\n      sync();\n      return () => {\n        cancelAnimationFrame(raf);\n        events.forEach((e) => el.removeEventListener(e, sync));\n      };\n    },\n    [el],\n  );\n  return useSyncExternalStore(subscribe, () => el?.currentTime ?? 0, () => 0);\n}\n\nexport function 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 ss = String(s % 60).padStart(2, \"0\");\n  return h ? `${h}:${String(m).padStart(2, \"0\")}:${ss}` : `${m}:${ss}`;\n}\n\nfunction speakTime(seconds: number) {\n  const s = Math.max(0, Math.floor(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\nfunction assignRef<T>(ref: React.Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) ref.current = value;\n}\n\n// Only one player speaks at a time: starting one pauses the rest on the page.\nconst PLAY_EVENT = \"stealth-audio-play\";\n\n/* ------------------------------------------------------------------ component */\n\nexport type AudioPlayerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  src: string;\n  /** Loudness per slice, 0–1, any length. Computed from `src` when missing (needs CORS). */\n  peaks?: number[];\n  /** Number of bars drawn. */\n  bars?: number;\n  /** Known length in seconds, shown before the file's metadata arrives. */\n  duration?: number;\n  /** Accessible name, e.g. \"Voice message from Maya\". */\n  label?: string;\n  /** Speeds the chip cycles through. Pass one value to hide the chip. */\n  rates?: number[];\n  /** Pause every other player on the page when this one starts. */\n  exclusive?: boolean;\n  /** `soft` quiets the play button when something else on the row is the primary action (a Send button). */\n  playStyle?: \"solid\" | \"soft\";\n  /** `card` brings its own surface; `bare` sits inside yours (a chat bubble, a row). */\n  variant?: \"card\" | \"bare\";\n  preload?: \"none\" | \"metadata\" | \"auto\";\n  onEnded?: () => void;\n  audioRef?: React.Ref<HTMLAudioElement>;\n};\n\nexport function AudioPlayer({\n  src,\n  peaks: peaksProp,\n  bars = 48,\n  duration: durationProp,\n  label = \"Audio\",\n  rates = [1, 1.5, 2],\n  exclusive = true,\n  variant = \"card\",\n  playStyle = \"solid\",\n  preload = \"metadata\",\n  onEnded,\n  audioRef,\n  className,\n  ...rest\n}: AudioPlayerProps) {\n  const reduce = !!useReducedMotion();\n  const [audio, setAudio] = useState<HTMLAudioElement | null>(null);\n  const media = useRef<HTMLAudioElement | null>(null);\n  const attach = useCallback(\n    (el: HTMLAudioElement | null) => {\n      media.current = el;\n      setAudio(el);\n      assignRef(audioRef, el);\n    },\n    [audioRef],\n  );\n\n  const paused = useMediaValue(audio, \"play pause ended emptied\", (a) => a.paused, true);\n  const metaDuration = useMediaValue(audio, \"loadedmetadata durationchange emptied\", (a) => (Number.isFinite(a.duration) ? a.duration : 0), 0);\n  const rate = useMediaValue(audio, \"ratechange\", (a) => a.playbackRate, 1);\n  const networkWaiting = useMediaValue(audio, \"play waiting playing canplay pause seeked emptied error\", (a) => !a.paused && a.readyState < 3, false);\n  const time = useSmoothTime(audio);\n  const duration = metaDuration || durationProp || 0;\n\n  // Bars keep a 2px width and at least ~2.5px of air: a narrow player draws fewer, never a solid block.\n  const [fit, setFit] = useState(bars);\n  const waveRef = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    const el = waveRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setFit(Math.max(12, Math.min(bars, Math.floor(entry.contentRect.width / 4.5)))));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [bars]);\n  const { peaks, status, animateIn } = useWaveform(src, peaksProp, fit);\n  const [failed, setFailed] = useState(false);\n  const [started, setStarted] = useState(false);\n  const [drag, setDrag] = useState<number | null>(null);\n  // A seek before the file has loaded is remembered by the element but fires no events; show it anyway.\n  const [pending, setPending] = useState<number | null>(null);\n  const [hover, setHover] = useState<number | null>(null);\n  const [rewinding, setRewinding] = useState(false);\n  const [waiting, setWaiting] = useState(false);\n  const rewindTimer = useRef<number>(undefined);\n  const onEndedRef = useRef(onEnded);\n  useEffect(() => {\n    onEndedRef.current = onEnded;\n  });\n\n  useEffect(() => {\n    const t = window.setTimeout(() => setWaiting(networkWaiting), networkWaiting ? 250 : 0);\n    return () => window.clearTimeout(t);\n  }, [networkWaiting]);\n\n  useEffect(() => {\n    const el = media.current;\n    if (!el) return;\n    const onError = () => setFailed(true);\n    const onPlay = () => {\n      setStarted(true);\n      if (exclusive) window.dispatchEvent(new CustomEvent(PLAY_EVENT, { detail: el }));\n    };\n    const onOther = (e: Event) => {\n      if ((e as CustomEvent).detail !== el && !el.paused) el.pause();\n    };\n    const onEnd = () => {\n      // A finished message rewinds to the start, so the next press plays it again.\n      window.clearTimeout(rewindTimer.current);\n      setRewinding(true);\n      el.currentTime = 0;\n      rewindTimer.current = window.setTimeout(() => setRewinding(false), 420);\n      onEndedRef.current?.();\n    };\n    el.addEventListener(\"error\", onError);\n    el.addEventListener(\"play\", onPlay);\n    el.addEventListener(\"ended\", onEnd);\n    const clearPending = () => setPending(null);\n    el.addEventListener(\"seeked\", clearPending);\n    el.addEventListener(\"emptied\", clearPending);\n    window.addEventListener(PLAY_EVENT, onOther);\n    if (el.error) onError();\n    return () => {\n      el.removeEventListener(\"error\", onError);\n      el.removeEventListener(\"play\", onPlay);\n      el.removeEventListener(\"ended\", onEnd);\n      el.removeEventListener(\"seeked\", clearPending);\n      el.removeEventListener(\"emptied\", clearPending);\n      window.removeEventListener(PLAY_EVENT, onOther);\n    };\n  }, [exclusive]);\n\n  useEffect(() => () => window.clearTimeout(rewindTimer.current), []);\n\n  const toggle = useCallback(() => {\n    const el = media.current;\n    if (!el) return;\n    if (el.paused) el.play().catch(noop);\n    else el.pause();\n  }, []);\n\n  const retry = useCallback(() => {\n    const el = media.current;\n    if (!el) return;\n    setFailed(false);\n    el.load();\n  }, []);\n\n  const cycleRate = useCallback(() => {\n    const el = media.current;\n    if (!el) return;\n    const i = rates.indexOf(el.playbackRate);\n    el.playbackRate = rates[(i + 1) % rates.length] ?? 1;\n  }, [rates]);\n\n  const seek = (el: HTMLAudioElement, t: number) => {\n    el.currentTime = t;\n    if (el.readyState === 0) setPending(t);\n  };\n\n  const shownTime = drag ?? (hover !== null ? hover : null);\n  const now = pending ?? time;\n  const progress = duration ? Math.min(1, (drag ?? now) / duration) : 0;\n  const readout = shownTime !== null ? shownTime : started || now > 0 ? now : duration;\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      data-state={failed ? \"error\" : paused ? \"paused\" : \"playing\"}\n      data-variant={variant}\n      className={cn(\n        \"flex min-w-0 items-center gap-3\",\n        variant === \"card\" && \"rounded-xl border border-line bg-raised py-2 pl-2 pr-3 shadow-[var(--shadow)]\",\n        className,\n      )}\n      {...rest}\n    >\n      <audio ref={attach} src={src} preload={preload} />\n\n      <PlayButton soft={playStyle === \"soft\"} paused={paused} failed={failed} waiting={waiting && !failed} reduce={reduce} onToggle={toggle} onRetry={retry} label={label} />\n\n      <div ref={waveRef} className=\"relative h-8 min-w-0 flex-1\">\n        {failed ? (\n          <p role=\"alert\" className=\"flex h-full items-center truncate text-[12.5px] text-danger\">\n            Couldn’t load this recording\n          </p>\n        ) : (\n          <Slider.Root\n            value={duration ? Math.min(drag ?? now, duration) : 0}\n            min={0}\n            max={duration || 1}\n            step={0.01}\n            disabled={!duration}\n            onValueChange={(v, details) => {\n              const el = media.current;\n              if (!el || !duration) return;\n              if (details.reason === \"keyboard\") {\n                details.cancel();\n                const key = (details.event as KeyboardEvent).key;\n                const jump = key === \"PageUp\" || key === \"PageDown\" ? 15 : 5;\n                const dir = key === \"ArrowRight\" || key === \"ArrowUp\" || key === \"PageUp\" ? 1 : key === \"Home\" ? -Infinity : key === \"End\" ? Infinity : -1;\n                seek(el, Math.min(duration - 0.05, Math.max(0, (pending ?? el.currentTime) + dir * jump)));\n                return;\n              }\n              setDrag(v);\n            }}\n            onValueCommitted={(v, details) => {\n              const el = media.current;\n              if (!el || details.reason === \"keyboard\") return;\n              seek(el, Math.min(v, duration - 0.05));\n              setDrag(null);\n            }}\n            className=\"group/wave h-full\"\n          >\n            <Slider.Control\n              onPointerMove={(e) => {\n                if (e.pointerType !== \"mouse\" || !duration) return;\n                const r = e.currentTarget.getBoundingClientRect();\n                setHover(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) * duration);\n              }}\n              onPointerLeave={() => setHover(null)}\n              className={cn(\n                \"relative h-full w-full cursor-pointer touch-none rounded-md data-disabled:cursor-default\",\n                \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-3\",\n              )}\n            >\n              <Slider.Track className=\"absolute inset-0 h-full w-full\">\n                <Bars peaks={peaks} tone=\"rest\" animateIn={animateIn && !reduce} dim={status === \"loading\"} />\n                {hover !== null && drag === null && (\n                  <div aria-hidden className=\"absolute inset-0\" style={{ clipPath: `inset(0 ${100 - (hover / (duration || 1)) * 100}% 0 0)` }}>\n                    <Bars peaks={peaks} tone=\"hover\" />\n                  </div>\n                )}\n                <div\n                  aria-hidden\n                  className={cn(\"absolute inset-0\", rewinding && \"transition-[clip-path] duration-[400ms] ease-in-out-quart motion-reduce:transition-none\")}\n                  style={{ clipPath: `inset(0 ${100 - progress * 100}% 0 0)` }}\n                >\n                  <Bars peaks={peaks} tone=\"played\" />\n                </div>\n                <Slider.Thumb\n                  aria-label={`Seek ${label}`}\n                  getAriaValueText={(_, v) => `${speakTime(v)} of ${speakTime(duration)}`}\n                  onKeyDown={(e) => {\n                    if (e.key === \" \" || e.key === \"k\") {\n                      e.preventDefault();\n                      e.stopPropagation();\n                      toggle();\n                    }\n                  }}\n                  // The playhead: only drawn while it's being moved or focused, otherwise the fill edge says it all.\n                  className=\"h-full w-0.5 rounded-full bg-fg opacity-0 transition-opacity duration-150 data-dragging:opacity-100 has-[:focus-visible]:opacity-100\"\n                />\n              </Slider.Track>\n            </Slider.Control>\n          </Slider.Root>\n        )}\n      </div>\n\n      <span className={cn(\"tabular grid shrink-0 text-right font-mono text-[11.5px] transition-colors duration-150\", shownTime !== null ? \"text-fg\" : \"text-fg-2\")}>\n        {/* Reserve the widest value so nothing shifts as the digits change. */}\n        <span aria-hidden className=\"invisible col-start-1 row-start-1\">{formatTime(duration).replace(/\\d/g, \"0\")}</span>\n        <span className=\"col-start-1 row-start-1\" aria-hidden>{formatTime(readout)}</span>\n        <span className=\"sr-only\">{duration ? `${formatTime(time)} of ${formatTime(duration)}` : \"\"}</span>\n      </span>\n\n      {rates.length > 1 && <RateChip rate={rate} rates={rates} reduce={reduce} disabled={failed} onPress={cycleRate} />}\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ parts */\n\nconst Bars = memo(function Bars({ peaks, tone, animateIn = false, dim = false }: { peaks: number[]; tone: \"rest\" | \"hover\" | \"played\"; animateIn?: boolean; dim?: boolean }) {\n  return (\n    <div className=\"absolute inset-0 flex items-center justify-between\" aria-hidden>\n      {peaks.map((p, i) => (\n        <motion.span\n          key={i}\n          className={cn(\n            \"w-[2px] shrink-0 rounded-full transition-[background-color,opacity] duration-200\",\n            tone === \"played\" ? \"bg-fg\" : tone === \"hover\" ? \"bg-fg/45\" : \"bg-fg/20\",\n            dim && \"opacity-60\",\n          )}\n          style={{ height: `${Math.max(10, Math.round(p * 100))}%`, originY: 0.5 }}\n          // Freshly decoded bars rise from the center line in a quick left-to-right sweep, once.\n          initial={animateIn ? { scaleY: 0.15 } : false}\n          animate={{ scaleY: 1 }}\n          transition={{ duration: 0.32, ease: ease.out, delay: animateIn ? i * 0.006 : 0 }}\n        />\n      ))}\n    </div>\n  );\n});\n\nconst PLAY_L = \"M4.6 2.9L8.6 5.3L8.6 10.7L4.6 13.1Z\";\nconst PLAY_R = \"M8.6 5.3L12.9 8L12.9 8L8.6 10.7Z\";\nconst PAUSE_L = \"M4 3L7 3L7 13L4 13Z\";\nconst PAUSE_R = \"M9 3L12 3L12 13L9 13Z\";\n\nfunction PlayButton({\n  soft,\n  paused,\n  failed,\n  waiting,\n  reduce,\n  onToggle,\n  onRetry,\n  label,\n}: {\n  paused: boolean;\n  failed: boolean;\n  waiting: boolean;\n  reduce: boolean;\n  onToggle: () => void;\n  onRetry: () => void;\n  label: string;\n  soft: boolean;\n}) {\n  const t = reduce ? { duration: 0 } : { duration: 0.2, ease: ease.inOut };\n  return (\n    <button\n      type=\"button\"\n      onClick={failed ? onRetry : onToggle}\n      aria-label={failed ? `Retry loading ${label}` : paused ? `Play ${label}` : `Pause ${label}`}\n      aria-busy={waiting || undefined}\n      className={cn(\n        \"relative grid size-8 shrink-0 place-items-center rounded-full outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out-quart active:scale-[0.92] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n        failed ? \"bg-danger-soft text-danger\" : soft ? \"bg-fg/10 text-fg hover:bg-fg/15\" : \"bg-fg text-frame hover:bg-fg/90\",\n      )}\n    >\n      {failed ? (\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n          <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n        </svg>\n      ) : (\n        <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" stroke=\"currentColor\" strokeWidth={1.2} strokeLinejoin=\"round\" aria-hidden>\n          <motion.path initial={false} animate={{ d: paused ? PLAY_L : PAUSE_L }} transition={t} />\n          <motion.path initial={false} animate={{ d: paused ? PLAY_R : PAUSE_R }} transition={t} />\n        </svg>\n      )}\n      {/* Buffering: an arc runs round the button instead of replacing the icon. */}\n      <AnimatePresence>\n        {waiting && (\n          <motion.svg\n            key=\"ring\"\n            aria-hidden\n            viewBox=\"0 0 40 40\"\n            className=\"pointer-events-none absolute -inset-1 size-10 animate-spin text-fg [animation-duration:0.9s]\"\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=\"28 200\" />\n          </motion.svg>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n\nfunction RateChip({ rate, rates, reduce, disabled, onPress }: { rate: number; rates: number[]; reduce: boolean; disabled: boolean; onPress: () => void }) {\n  const next = rates[(rates.indexOf(rate) + 1) % rates.length] ?? 1;\n  return (\n    <button\n      type=\"button\"\n      onClick={onPress}\n      disabled={disabled}\n      aria-label={`Playback speed ${rate}×, switch to ${next}×`}\n      className={cn(\n        \"relative grid h-6 shrink-0 place-items-center overflow-hidden rounded-md border border-line-2 px-1.5 font-mono text-[11px] tabular text-fg-2 outline-none\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart hover:border-fg-4 hover:text-fg active:scale-[0.94] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 disabled:pointer-events-none disabled:opacity-50\",\n        \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n        rate !== 1 && \"border-fg-4 text-fg\",\n      )}\n    >\n      {rates.map((r) => (\n        <span key={r} aria-hidden className=\"invisible col-start-1 row-start-1\">{`${r}×`}</span>\n      ))}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={rate}\n          className=\"col-start-1 row-start-1\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n          transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n        >\n          {`${rate}×`}\n        </motion.span>\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/audio-player"
}
