{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"waveform-player","type":"registry:component","title":"Waveform player","description":"Inks in a podcast's waveform, with chapter markers and a ghost playhead.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/waveform-player.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: the waveform is drawn twice, a quiet copy and an inked\n   copy clipped to the playhead, so progress is one clip-path write per frame\n   instead of recolouring a hundred bars. Position lives in a motion value:\n   playback sets it, and seeks and scrubs spring it, so every jump glides\n   and a scrub that changes direction keeps its momentum. */\n\nexport type Chapter = { start: number; title: string };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Critically damped: the ink should land on the spot you clicked, never\n// overshoot past it and come back.\nconst SEEK = { type: \"spring\", visualDuration: 0.3, bounce: 0 } as const;\n// Tighter while dragging, so the ink stays under the finger.\nconst SCRUB = { type: \"spring\", visualDuration: 0.12, bounce: 0 } as const;\nconst RATES = [1, 1.5, 2] as const;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\nconst fmt = (s: number) => {\n  const t = Math.max(0, Math.floor(s));\n  const h = Math.floor(t / 3600);\n  const m = Math.floor((t % 3600) / 60);\n  const sec = String(t % 60).padStart(2, \"0\");\n  return h ? `${h}:${String(m).padStart(2, \"0\")}:${sec}` : `${m}:${sec}`;\n};\n\nfunction mulberry32(seed: number) {\n  return () => {\n    seed |= 0;\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// Stand-in peaks with the texture of a conversation: turns of speech at\n// different loudness, short breaths between them, a steady music bed at the\n// top, and a real pause wherever a chapter begins. Seeded, so the server and\n// client draw the same wave.\nexport function speechPeaks(\n  count: number,\n  duration: number,\n  chapters: Chapter[] = [],\n  seed = 7,\n) {\n  const rand = mulberry32(seed);\n  const out: number[] = [];\n  let level = 0.6;\n  let run = 0;\n  const breaks = new Set(\n    chapters.slice(1).map((c) => Math.round((c.start / duration) * count)),\n  );\n  for (let i = 0; i < count; i++) {\n    if (i < 4) {\n      // The intro music: loud and even, unlike speech.\n      out.push(0.72 + rand() * 0.08);\n      continue;\n    }\n    if (breaks.has(i) || breaks.has(i + 1)) {\n      out.push(0.08 + rand() * 0.06);\n      run = 0;\n      continue;\n    }\n    if (run <= 0) {\n      if (rand() < 0.2) {\n        level = 0.1 + rand() * 0.1;\n        run = 1;\n      } else {\n        level = 0.4 + rand() * 0.55;\n        run = 3 + Math.floor(rand() * 7);\n      }\n    }\n    out.push(Math.min(1, level * (0.55 + rand() * 0.45)));\n    run--;\n  }\n  return out;\n}\n\nexport function WaveformPlayer({\n  title,\n  show,\n  duration: durationProp,\n  chapters,\n  peaks: peaksProp,\n  src,\n  cover,\n  className,\n}: {\n  title: string;\n  show: string;\n  // Seconds. Used until the audio reports its own, or throughout when there\n  // is no audio and the player runs on a simulated clock.\n  duration: number;\n  chapters: Chapter[];\n  // 0 to 1 per bar; generated when omitted.\n  peaks?: number[];\n  src?: string;\n  cover?: React.ReactNode;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const id = useId();\n  const audio = useRef<HTMLAudioElement>(null);\n  const [duration, setDuration] = useState(durationProp);\n  const peaks = useMemo(\n    () => peaksProp ?? speechPeaks(112, durationProp, chapters),\n    [peaksProp, durationProp, chapters],\n  );\n  const barChapter = useMemo(\n    () =>\n      peaks.map((_, i) => {\n        const t = ((i + 0.5) / peaks.length) * duration;\n        let c = 0;\n        chapters.forEach((ch, j) => {\n          if (t >= ch.start) c = j;\n        });\n        return c;\n      }),\n    [peaks, chapters, duration],\n  );\n\n  const [playing, setPlaying] = useState(false);\n  const [rate, setRate] = useState<(typeof RATES)[number]>(1);\n  const [chapter, setChapter] = useState(0);\n\n  const root = useRef<HTMLDivElement>(null);\n  const wave = useRef<HTMLDivElement>(null);\n  const ink = useRef<HTMLDivElement>(null);\n  const chapterInk = useRef<HTMLDivElement>(null);\n  const quietSegs = useRef<(HTMLSpanElement | null)[]>([]);\n  const inkSegs = useRef<(HTMLSpanElement | null)[]>([]);\n  const head = useRef<HTMLDivElement>(null);\n  const ghost = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const tipTime = useRef<HTMLSpanElement>(null);\n  const tipLabel = useRef<HTMLSpanElement>(null);\n  const now = useRef<HTMLSpanElement>(null);\n  const baseBars = useRef<(HTMLSpanElement | null)[]>([]);\n\n  // The clock. Simulated playback anchors to wall time, so a paused rAF\n  // (offscreen, background tab) catches up instead of drifting.\n  const pos = useRef(0);\n  const anchor = useRef({ t: 0, wall: 0 });\n  const rateRef = useRef<number>(1);\n  const playingRef = useRef(false);\n  const dragging = useRef(false);\n  const seekAnim = useRef<AnimationPlaybackControls | null>(null);\n  const shown = useMotionValue(0);\n  const lastSecond = useRef(-1);\n  const hoverChapter = useRef(-1);\n\n  const chapterAt = useCallback(\n    (t: number) => {\n      let c = 0;\n      chapters.forEach((ch, j) => {\n        if (t >= ch.start) c = j;\n      });\n      return c;\n    },\n    [chapters],\n  );\n\n  // Every visual that follows the position, written straight to the DOM.\n  const draw = useCallback(\n    (t: number) => {\n      const p = duration ? Math.min(Math.max(t / duration, 0), 1) : 0;\n      const clip = `inset(0 ${(1 - p) * 100}% 0 0)`;\n      if (ink.current) ink.current.style.clipPath = clip;\n      if (chapterInk.current) chapterInk.current.style.clipPath = clip;\n      if (head.current) head.current.style.left = `${p * 100}%`;\n      const sec = Math.floor(t);\n      if (sec !== lastSecond.current) {\n        lastSecond.current = sec;\n        if (now.current) now.current.textContent = fmt(t);\n        const c = chapterAt(t);\n        wave.current?.setAttribute(\"aria-valuenow\", String(sec));\n        wave.current?.setAttribute(\n          \"aria-valuetext\",\n          `${fmt(t)} of ${fmt(duration)}, ${chapters[c].title}`,\n        );\n        setChapter(c);\n      }\n    },\n    [duration, chapters, chapterAt],\n  );\n\n  useMotionValueEvent(shown, \"change\", draw);\n\n  const clock = () =>\n    src && audio.current\n      ? audio.current.currentTime\n      : playingRef.current\n        ? anchor.current.t + ((performance.now() - anchor.current.wall) / 1000) * rateRef.current\n        : pos.current;\n\n  const seek = (t: number, spring: typeof SEEK | typeof SCRUB | null = SEEK) => {\n    const next = Math.min(Math.max(t, 0), duration);\n    pos.current = next;\n    anchor.current = { t: next, wall: performance.now() };\n    if (src && audio.current) audio.current.currentTime = next;\n    if (!spring || reduceMotion) {\n      seekAnim.current?.stop();\n      seekAnim.current = null;\n      shown.set(next);\n      return;\n    }\n    // Retargeting the running spring keeps its velocity.\n    const anim = animate(shown, next, spring);\n    seekAnim.current = anim;\n    anim.then(() => {\n      if (seekAnim.current === anim) seekAnim.current = null;\n    });\n  };\n\n  // The playback loop. Runs only while playing and on screen.\n  const visible = useRef(true);\n  const wake = useRef(() => {});\n  useEffect(() => {\n    if (!playing) return;\n    let raf = 0;\n    const loop = () => {\n      raf = 0;\n      if (!visible.current) return;\n      let t = clock();\n      if (!src && t >= duration) {\n        t = duration;\n        pos.current = t;\n        playingRef.current = false;\n        setPlaying(false);\n      }\n      // A seek or scrub owns the playhead until its spring settles.\n      if (!seekAnim.current && !dragging.current) shown.set(t);\n      raf = requestAnimationFrame(loop);\n    };\n    wake.current = () => {\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n    wake.current();\n    return () => {\n      cancelAnimationFrame(raf);\n      wake.current = () => {};\n    };\n    // clock only reads refs, so it never needs to restart the loop.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [playing, src, duration, shown]);\n\n  useEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([entry]) => {\n      visible.current = entry.isIntersecting;\n      wake.current();\n    });\n    io.observe(el);\n    return () => {\n      io.disconnect();\n      seekAnim.current?.stop();\n    };\n  }, []);\n\n  const toggle = () => {\n    const next = !playingRef.current;\n    if (next && pos.current >= duration) pos.current = 0;\n    const t = next ? pos.current : clock();\n    pos.current = t;\n    anchor.current = { t, wall: performance.now() };\n    playingRef.current = next;\n    setPlaying(next);\n    if (src && audio.current) {\n      if (next) audio.current.play().catch(() => {\n        playingRef.current = false;\n        setPlaying(false);\n      });\n      else audio.current.pause();\n    }\n  };\n\n  const cycleRate = () => {\n    const next = RATES[(RATES.indexOf(rate) + 1) % RATES.length];\n    // Re-anchor so the new speed applies from here, not from play.\n    const t = clock();\n    pos.current = t;\n    anchor.current = { t, wall: performance.now() };\n    rateRef.current = next;\n    if (audio.current) audio.current.playbackRate = next;\n    setRate(next);\n  };\n\n  // Hover and scrub feedback: a ghost playhead and a time preview.\n  const showTip = (x: number, t: number, label: string) => {\n    const w = wave.current;\n    const el = tip.current;\n    if (!w || !el) return;\n    const width = w.offsetWidth;\n    const half = el.offsetWidth / 2;\n    const cx = Math.min(Math.max(x, half), width - half);\n    el.style.transform = `translate(${cx}px, 0) translateX(-50%)`;\n    el.style.opacity = \"1\";\n    if (tipTime.current) tipTime.current.textContent = fmt(t);\n    if (tipLabel.current) tipLabel.current.textContent = label;\n  };\n  const hideTip = () => {\n    if (tip.current) tip.current.style.opacity = \"0\";\n    if (ghost.current) ghost.current.style.opacity = \"0\";\n    tintChapter(-1);\n  };\n\n  // The chapter under the cursor lifts out of the quiet copy of the wave.\n  const tintChapter = (c: number) => {\n    if (c === hoverChapter.current) return;\n    hoverChapter.current = c;\n    baseBars.current.forEach((bar, i) => {\n      // Twice the resting 0.2, still well short of the played ink.\n      if (bar) bar.style.opacity = c >= 0 && barChapter[i] === c ? \"0.4\" : \"\";\n    });\n    // Its segment of the chapter track thickens too, quiet and inked alike.\n    [quietSegs, inkSegs].forEach((segs) =>\n      segs.current.forEach((seg, i) => {\n        if (seg) seg.style.scale = i === c ? \"1 2\" : \"\";\n      }),\n    );\n  };\n\n  const timeAt = (clientX: number) => {\n    const box = wave.current!.getBoundingClientRect();\n    const x = Math.min(Math.max(clientX - box.left, 0), box.width);\n    return { x, t: (x / box.width) * duration };\n  };\n\n  const hover = (clientX: number) => {\n    const { x, t } = timeAt(clientX);\n    if (ghost.current) {\n      ghost.current.style.transform = `translateX(${x}px)`;\n      ghost.current.style.opacity = \"1\";\n    }\n    const c = chapterAt(t);\n    tintChapter(c);\n    showTip(x, t, chapters[c].title);\n  };\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.button !== 0) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    dragging.current = true;\n    const { t } = timeAt(e.clientX);\n    seek(t);\n    hover(e.clientX);\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    if (dragging.current) {\n      seek(timeAt(e.clientX).t, SCRUB);\n      hover(e.clientX);\n    } else if (e.pointerType !== \"touch\") hover(e.clientX);\n  };\n  const onPointerUp = (e: React.PointerEvent) => {\n    if (!dragging.current) return;\n    dragging.current = false;\n    seek(timeAt(e.clientX).t, SCRUB);\n    if (e.pointerType === \"touch\") hideTip();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const t = clock();\n    pos.current = t;\n    const step = {\n      ArrowRight: 5,\n      ArrowUp: 5,\n      ArrowLeft: -5,\n      ArrowDown: -5,\n      PageUp: 30,\n      PageDown: -30,\n    }[e.key];\n    if (step !== undefined) {\n      e.preventDefault();\n      seek(t + step);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      seek(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      seek(duration);\n    } else if (e.key === \" \" || e.key === \"k\") {\n      e.preventDefault();\n      toggle();\n    }\n  };\n\n  const current = chapters[chapter];\n\n  return (\n    <div\n      ref={root}\n      className={cn(\n        \"w-[540px] max-w-full rounded-2xl bg-muted p-5 shadow-raised\",\n        className,\n      )}\n    >\n      {src && (\n        <audio\n          ref={audio}\n          src={src}\n          preload=\"metadata\"\n          onLoadedMetadata={(e) => {\n            const d = e.currentTarget.duration;\n            if (Number.isFinite(d)) setDuration(d);\n          }}\n          onEnded={() => {\n            playingRef.current = false;\n            setPlaying(false);\n          }}\n        />\n      )}\n\n      <div className=\"flex items-center gap-4\">\n        <div className=\"size-14 shrink-0 overflow-hidden rounded-xl\">{cover}</div>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"truncate text-[15px] font-semibold text-foreground\">{title}</p>\n          <p className=\"truncate text-sm text-muted-foreground\">{show}</p>\n        </div>\n      </div>\n\n      <div className=\"mt-5 flex h-5 items-center gap-2 text-sm\">\n        <span className=\"shrink-0 text-muted-foreground tabular-nums\">\n          {chapter + 1}/{chapters.length}\n        </span>\n        <div className=\"relative h-5 min-w-0 flex-1\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.p\n              key={chapter}\n              initial={{ opacity: 0, filter: \"blur(4px)\", y: reduceMotion ? 0 : 4 }}\n              animate={{ opacity: 1, filter: \"blur(0px)\", y: 0, transition: { duration: 0.24, ease: EASE_OUT } }}\n              exit={{ opacity: 0, filter: \"blur(2px)\", transition: { duration: 0.12, ease: EASE_OUT } }}\n              className=\"truncate font-medium text-foreground\"\n            >\n              {current.title}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n      </div>\n\n      {/* Room above the wave for the time preview. */}\n      <div className=\"relative mt-9\">\n        <div\n          ref={tip}\n          aria-hidden\n          className=\"pointer-events-none absolute bottom-full left-0 mb-2 flex items-baseline gap-1.5 rounded-lg bg-foreground px-2 py-1 text-xs whitespace-nowrap text-background opacity-0 transition-opacity duration-150 ease-out\"\n        >\n          <span ref={tipTime} className=\"font-medium tabular-nums\" />\n          <span ref={tipLabel} className=\"max-w-40 truncate text-background/65\" />\n        </div>\n\n        <div\n          ref={wave}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Seek\"\n          aria-valuemin={0}\n          aria-valuemax={Math.floor(duration)}\n          aria-valuenow={0}\n          aria-valuetext={`0:00 of ${fmt(duration)}, ${chapters[0].title}`}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerUp}\n          onPointerLeave={(e) => {\n            if (!dragging.current && e.pointerType !== \"touch\") hideTip();\n          }}\n          onKeyDown={onKeyDown}\n          className=\"relative h-14 cursor-pointer touch-none rounded-md outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground\"\n        >\n          <Bars peaks={peaks} className=\"text-foreground\" barClassName=\"opacity-20\" refs={baseBars} />\n          <div ref={ink} className=\"absolute inset-0\" style={{ clipPath: \"inset(0 100% 0 0)\" }}>\n            <Bars peaks={peaks} className=\"text-foreground\" />\n          </div>\n          <div\n            ref={ghost}\n            aria-hidden\n            className=\"pointer-events-none absolute inset-y-0 left-0 w-px bg-foreground/35 opacity-0 transition-opacity duration-150 ease-out\"\n          />\n          <div\n            ref={head}\n            aria-hidden\n            className=\"pointer-events-none absolute -inset-y-1 left-0 w-0.5 -translate-x-1/2 rounded-full bg-foreground\"\n          />\n        </div>\n\n        {/* Chapters as a segmented track under the wave, inked by the same\n            clip as the bars. Each segment jumps to its chapter's start. */}\n        <div className=\"relative mt-1 h-6\">\n          {chapters.map((c, i) => {\n            const left = (c.start / duration) * 100;\n            const end = chapters[i + 1]?.start ?? duration;\n            const width = ((end - c.start) / duration) * 100;\n            return (\n              <button\n                key={c.start}\n                type=\"button\"\n                aria-label={`Chapter ${i + 1}: ${c.title}, ${fmt(c.start)}`}\n                onClick={() => seek(c.start)}\n                onPointerEnter={(e) => {\n                  if (e.pointerType === \"touch\" || !wave.current) return;\n                  showTip((left / 100) * wave.current.offsetWidth, c.start, c.title);\n                  tintChapter(i);\n                }}\n                onPointerLeave={hideTip}\n                onFocus={(e) => {\n                  if (!wave.current || !e.currentTarget.matches(\":focus-visible\")) return;\n                  showTip((left / 100) * wave.current.offsetWidth, c.start, c.title);\n                  tintChapter(i);\n                }}\n                onBlur={hideTip}\n                className=\"absolute top-0 h-6 rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n                style={{ left: `${left}%`, width: `${width}%` }}\n              />\n            );\n          })}\n          <Segments chapters={chapters} duration={duration} className=\"bg-foreground/20\" refs={quietSegs} />\n          <div ref={chapterInk} className=\"pointer-events-none absolute inset-0\" style={{ clipPath: \"inset(0 100% 0 0)\" }}>\n            <Segments chapters={chapters} duration={duration} className=\"bg-foreground\" refs={inkSegs} />\n          </div>\n        </div>\n      </div>\n\n      <div className=\"mt-2 grid grid-cols-[1fr_auto_1fr] items-center gap-2\">\n        <p className=\"text-sm text-muted-foreground tabular-nums\">\n          <span ref={now} className=\"text-foreground\">0:00</span>\n          {/* Dropped on phones, where the controls need the width. */}\n          <span className=\"max-sm:hidden\"> / {fmt(duration)}</span>\n        </p>\n        <div className=\"flex items-center gap-1\">\n          <SkipButton label=\"Back 15 seconds\" onClick={() => seek(clock() - 15)} dir={-1} />\n          <button\n            type=\"button\"\n            onClick={toggle}\n            aria-label={playing ? \"Pause\" : \"Play\"}\n            className={cn(\n              \"flex size-11 touch-manipulation items-center justify-center rounded-full bg-foreground text-background outline-hidden\",\n              \"transition-[scale] duration-150 ease-out active:scale-[0.96]\",\n              \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.svg\n                key={playing ? \"pause\" : \"play\"}\n                viewBox=\"0 0 16 16\"\n                className=\"size-4.5\"\n                fill=\"currentColor\"\n                aria-hidden\n                initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                transition={ICON_SWAP}\n              >\n                {playing ? (\n                  <path d=\"M4 2.75h2.5v10.5H4zM9.5 2.75H12v10.5H9.5z\" />\n                ) : (\n                  // Nudged right: a centred triangle looks off-centre.\n                  <path d=\"M5.25 2.6v10.8a.6.6 0 0 0 .9.5l8.1-5.4a.6.6 0 0 0 0-1l-8.1-5.4a.6.6 0 0 0-.9.5\" />\n                )}\n              </motion.svg>\n            </AnimatePresence>\n          </button>\n          <SkipButton label=\"Forward 30 seconds\" onClick={() => seek(clock() + 30)} dir={1} />\n        </div>\n        <div className=\"flex justify-end\">\n          <button\n            type=\"button\"\n            onClick={cycleRate}\n            aria-label={`Playback speed ${rate}x`}\n            className={cn(\n              \"relative flex h-9 w-14 touch-manipulation items-center justify-center overflow-hidden rounded-full bg-background text-sm font-medium text-foreground tabular-nums shadow-raised outline-hidden\",\n              \"transition-[scale] duration-150 ease-out active:scale-[0.96]\",\n              \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={rate}\n                initial={{ opacity: 0, y: reduceMotion ? 0 : 10, filter: \"blur(4px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: 0.22, ease: EASE_OUT } }}\n                exit={{ opacity: 0, y: reduceMotion ? 0 : -8, filter: \"blur(2px)\", transition: { duration: 0.14, ease: EASE_OUT } }}\n              >\n                {rate}x\n              </motion.span>\n            </AnimatePresence>\n          </button>\n        </div>\n      </div>\n      <span id={`${id}-status`} role=\"status\" className=\"sr-only\">\n        {playing ? `Playing at ${rate}x` : \"Paused\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Bars({\n  peaks,\n  className,\n  barClassName,\n  refs,\n}: {\n  peaks: number[];\n  className?: string;\n  barClassName?: string;\n  refs?: React.RefObject<(HTMLSpanElement | null)[]>;\n}) {\n  return (\n    // The gap is a share of the width, not a fixed 2px: 112 bars with 2px\n    // gaps need 334px and would spill out of a phone-width card.\n    <div aria-hidden className={cn(\"absolute inset-0 flex items-center gap-[0.45%]\", className)}>\n      {peaks.map((p, i) => (\n        <span\n          key={i}\n          ref={\n            refs\n              ? (el) => {\n                  refs.current[i] = el;\n                }\n              : undefined\n          }\n          className={cn(\n            \"min-w-px flex-1 rounded-full bg-current transition-opacity duration-150 ease-out\",\n            barClassName,\n          )}\n          // Never below 3px, so silence still reads as part of the track.\n          style={{ height: `max(3px, ${p * 100}%)` }}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction Segments({\n  chapters,\n  duration,\n  className,\n  refs,\n}: {\n  chapters: Chapter[];\n  duration: number;\n  className: string;\n  refs: React.RefObject<(HTMLSpanElement | null)[]>;\n}) {\n  return (\n    <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n      {chapters.map((c, i) => {\n        const end = chapters[i + 1]?.start ?? duration;\n        return (\n          <span\n            key={c.start}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            style={{\n              left: `${(c.start / duration) * 100}%`,\n              // A 3px gap before the next chapter, none after the last.\n              width: `calc(${((end - c.start) / duration) * 100}% - ${chapters[i + 1] ? 3 : 0}px)`,\n            }}\n            className={cn(\n              \"absolute top-[10px] h-1 rounded-full transition-[scale] duration-150 ease-out\",\n              className,\n            )}\n          />\n        );\n      })}\n    </div>\n  );\n}\n\nfunction SkipButton({\n  label,\n  dir,\n  onClick,\n}: {\n  label: string;\n  dir: 1 | -1;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-label={label}\n      className={cn(\n        \"flex h-10 touch-manipulation items-center justify-center gap-1 rounded-full px-2 text-muted-foreground outline-hidden hover:text-foreground\",\n        \"transition-[scale,color] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-[color]\",\n        \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-foreground\",\n      )}\n    >\n      <svg\n        viewBox=\"0 0 20 20\"\n        className={cn(\"size-4.5\", dir === 1 && \"order-last -scale-x-100\")}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        <path d=\"M4.5 9.5a6 6 0 1 0 1.8-4.3M4.5 3.5v3.2h3.2\" />\n      </svg>\n      <span aria-hidden className=\"text-[13px] font-medium tabular-nums\">\n        {dir === 1 ? \"30\" : \"15\"}\n      </span>\n    </button>\n  );\n}\n\nconst CHAPTERS: Chapter[] = [\n  { start: 0, title: \"Cold open\" },\n  { start: 312, title: \"Why we priced in public\" },\n  { start: 1044, title: \"The churn spreadsheet\" },\n  { start: 1788, title: \"Hiring a first designer\" },\n  { start: 2310, title: \"Listener questions\" },\n];\n\n// The show's artwork. Cover art is content, not interface, so it keeps its\n// own printed colours in both themes: burnt orange stock, cream ink, and a\n// stack of three jars for the \"small batch\".\nfunction CoverArt() {\n  const ORANGE = \"oklch(0.64 0.16 42)\";\n  const CREAM = \"oklch(0.96 0.03 85)\";\n  return (\n    <svg viewBox=\"0 0 56 56\" className=\"size-full\" aria-hidden>\n      <rect width=\"56\" height=\"56\" fill={ORANGE} />\n      <circle cx=\"44\" cy=\"12\" r=\"22\" fill=\"oklch(0.7 0.15 55)\" />\n      <g fill={CREAM}>\n        <rect x=\"8\" y=\"29\" width=\"9\" height=\"19\" rx=\"2.5\" />\n        <rect x=\"19.5\" y=\"24\" width=\"10\" height=\"24\" rx=\"2.5\" />\n        <rect x=\"32\" y=\"33\" width=\"8\" height=\"15\" rx=\"2.5\" />\n      </g>\n      <g fill={ORANGE}>\n        <rect x=\"10\" y=\"35\" width=\"5\" height=\"3\" rx=\"1\" />\n        <rect x=\"21.5\" y=\"31\" width=\"6\" height=\"3\" rx=\"1\" />\n        <rect x=\"34\" y=\"38\" width=\"4\" height=\"3\" rx=\"1\" />\n      </g>\n      <text x=\"8\" y=\"14\" fill={CREAM} fontSize=\"8\" fontWeight=\"700\" letterSpacing=\"-0.2\">\n        SMALL\n      </text>\n      <text x=\"8\" y=\"22\" fill={CREAM} fontSize=\"8\" fontWeight=\"700\" letterSpacing=\"-0.2\">\n        BATCH\n      </text>\n      <text x=\"48\" y=\"14\" fill={CREAM} fontSize=\"7\" fontWeight=\"600\" textAnchor=\"end\" opacity=\"0.85\">\n        42\n      </text>\n    </svg>\n  );\n}\n\n// Generated once at module scope: the same wave on every render.\nconst PEAKS = speechPeaks(112, 2538, CHAPTERS);\n\nexport default function WaveformPlayerDemo() {\n  return (\n    <WaveformPlayer\n      title=\"Pricing in public, one year later\"\n      show=\"Small Batch, Episode 42\"\n      duration={2538}\n      chapters={CHAPTERS}\n      peaks={PEAKS}\n      cover={<CoverArt />}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/waveform-player"}