{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-player",
  "type": "registry:block",
  "title": "Waveform Audio Player",
  "description": "An audio player drawn from the file's real waveform: hover to preview a jump, drag to scrub, and the playhead springs to its new spot with a ripple through the bars. Chapters, a rolling speed pill and full keyboard control.",
  "docs": "Waveform Audio Player was added to public/vividsites/audio-player.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/audio-player",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/audio-player.html",
      "type": "registry:file",
      "target": "public/vividsites/audio-player.html",
      "content": "<!--\nvs-audio-player: an audio player built around a real waveform. The file is decoded in the\n  browser into an RMS envelope and drawn as bars on a canvas: played bars take the accent, a\n  hover previews the stretch you would jump over, and a tooltip names the time and chapter.\n  Tap or drag to scrub; the playhead glides to the new spot on a spring and a ripple runs out\n  through the bars. Chapters are gaps in the waveform and a list with a live fill and a small\n  equaliser on the one playing. Speed cycles 1 / 1.25 / 1.5 / 2 / 0.75× with a rolling label.\n  Keys: Space or K play/pause, J/L ±15 s; on the waveform ←/→ ±5 s (Shift ±15), Home/End.\n\n  Markup: .vs-audio-player[data-src] > .vs-audio-player-head (cover img + titles)\n          + ol.vs-audio-player-chapters > li[data-start=\"seconds\"]   (optional)\n          The player builds the waveform, transport and speed pill itself. You may put an\n          <audio> element inside instead of data-src.\n  Attributes on .vs-audio-player:\n    data-src        the audio file (must be same-origin or CORS-enabled for the waveform)\n    data-style      \"bars\" (centred, default) or \"mirror\" (bars over a faint reflection)\n    data-chapters   \"off\" hides the chapter list (the waveform gaps stay)\n    data-peaks      optional comma-separated envelope; skips decoding (use for cross-origin files)\n  Modifier: .vs-audio-player--dark\n  CSS variables: --vs-audio-player-accent, -bg, -ink, -wave (unplayed bars), -radius,\n    -bar (bar width in px), -font, -mono\n  JS: el.__vsAudioPlayer.play() / pause() / toggle() / seek(seconds) / setRate(r) / refresh();\n      vsAudioPlayer.mount(el) for players added later. Starting one player pauses the others.\n  Reduced motion: no entrance, ripple or springs; the playhead jumps.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Mulish:wght@400;500&family=DM+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-audio-player\" data-src=\"assets/tide.mp3\" data-style=\"bars\">\n  <div class=\"vs-audio-player-head\">\n    <img class=\"vs-audio-player-cover\" src=\"assets/cover.webp\" alt=\"A swimmer crossing a green tidal pool, seen from above\">\n    <div class=\"vs-audio-player-titles\">\n      <p class=\"vs-audio-player-kicker\">Tidewater Tapes · No. 04</p>\n      <h3 class=\"vs-audio-player-title\">Slack Water</h3>\n    </div>\n  </div>\n  <ol class=\"vs-audio-player-chapters\">\n    <li data-start=\"0\">Harbour lights</li>\n    <li data-start=\"11.43\">Drift</li>\n    <li data-start=\"45.71\">Undertow</li>\n    <li data-start=\"80\">Low tide</li>\n    <li data-start=\"97.14\">Last light</li>\n  </ol>\n</div>\n\n<style>\n.vs-audio-player {\n  --vs-audio-player-accent: #0d6e66;              /* played bars, play button, active chapter */\n  --vs-audio-player-bg: #fbfcfb;                  /* card surface */\n  --vs-audio-player-ink: #0f1a19;                 /* text and icons */\n  --vs-audio-player-wave: rgba(15,26,25,.16);     /* unplayed bars */\n  --vs-audio-player-radius: 22px;\n  --vs-audio-player-bar: 3px;                     /* bar width; gap is 2/3 of it */\n  --vs-audio-player-font: \"Mulish\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n  --vs-audio-player-mono: \"DM Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n\n  position: relative; box-sizing: border-box; width: 100%; max-width: 640px;\n  padding: 20px 22px 14px; border-radius: var(--vs-audio-player-radius);\n  font-family: var(--vs-audio-player-font); color: var(--vs-audio-player-ink);\n  background: var(--vs-audio-player-bg);\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.07), 0 1px 0 rgba(255,255,255,.9) inset, 0 1px 2px rgba(10,30,28,.05),\n              0 12px 28px -12px rgba(10,30,28,.18), 0 40px 80px -40px rgba(10,30,28,.28);\n  -webkit-tap-highlight-color: transparent; -webkit-font-smoothing: antialiased;\n}\n.vs-audio-player *, .vs-audio-player *::before, .vs-audio-player *::after { box-sizing: border-box; }\n.vs-audio-player--dark {\n  --vs-audio-player-bg: #121716; --vs-audio-player-ink: #e9f1ef; --vs-audio-player-wave: rgba(233,241,239,.16);\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.08), 0 1px 0 rgba(255,255,255,.05) inset, 0 24px 60px -24px rgba(0,0,0,.7);\n}\n\n/* head */\n.vs-audio-player-head { display: flex; align-items: center; gap: 14px; min-width: 0; }\n.vs-audio-player-cover {\n  width: 56px; height: 56px; flex: none; border-radius: 12px; object-fit: cover; display: block;\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.12) inset, 0 6px 14px -6px rgba(10,30,28,.4);\n}\n.vs-audio-player-titles { flex: 1; min-width: 0; }\n.vs-audio-player-kicker {\n  margin: 0 0 4px; font: 500 11px/1 var(--vs-audio-player-mono); letter-spacing: .08em; text-transform: uppercase; opacity: .5;\n  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.vs-audio-player-title { margin: 0; font-size: 19px; font-weight: 500; line-height: 1.15; letter-spacing: -.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-audio-player-now { display: block; margin-top: 3px; font-size: 13px; line-height: 1.2; opacity: .56; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-audio-player-now b { font-weight: 500; opacity: 1; }\n\n/* speed pill */\n.vs-audio-player-speed {\n  all: unset; box-sizing: border-box; flex: none; position: relative; height: 30px; min-width: 52px; padding: 0 10px; border-radius: 99px;\n  display: inline-grid; place-items: center; cursor: pointer; overflow: hidden;\n  font: 500 12.5px/1 var(--vs-audio-player-mono); font-variant-numeric: tabular-nums; letter-spacing: -.02em;\n  background: color-mix(in srgb, var(--vs-audio-player-ink) 6%, transparent);\n  box-shadow: 0 0 0 .5px color-mix(in srgb, var(--vs-audio-player-ink) 10%, transparent) inset;\n  transition: background-color .15s cubic-bezier(.2,0,0,1), transform .3s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-audio-player-speed:hover { background: color-mix(in srgb, var(--vs-audio-player-ink) 10%, transparent); }\n.vs-audio-player-speed:active { transform: scale(.94); }\n.vs-audio-player-speed:focus-visible { box-shadow: 0 0 0 2px var(--vs-audio-player-bg), 0 0 0 3.5px var(--vs-audio-player-accent); }\n.vs-audio-player-speed span { grid-area: 1 / 1; will-change: transform, opacity; }\n.vs-audio-player-speed.is-fast { color: var(--vs-audio-player-accent); background: color-mix(in srgb, var(--vs-audio-player-accent) 12%, transparent); }\n\n/* waveform */\n.vs-audio-player-wave {\n  position: relative; height: 64px; margin: 20px 0 6px; cursor: pointer; touch-action: none; outline: none; border-radius: 8px;\n}\n.vs-audio-player-wave canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n.vs-audio-player-wave:focus-visible { box-shadow: 0 0 0 2px var(--vs-audio-player-bg), 0 0 0 3.5px var(--vs-audio-player-accent); }\n.vs-audio-player-head-line {\n  position: absolute; top: -4px; bottom: -4px; left: 0; width: 2px; margin-left: -1px; border-radius: 2px; pointer-events: none;\n  background: var(--vs-audio-player-ink); will-change: transform; opacity: .9;\n}\n.vs-audio-player-head-line::before {\n  content: \"\"; position: absolute; left: 50%; top: -3px; width: 8px; height: 8px; margin-left: -4px; border-radius: 50%;\n  background: var(--vs-audio-player-ink); box-shadow: 0 0 0 2px var(--vs-audio-player-bg);\n  transition: transform .25s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-audio-player.is-scrubbing .vs-audio-player-head-line::before { transform: scale(1.5); }\n.vs-audio-player-tip {\n  position: absolute; bottom: calc(100% + 10px); left: 0; pointer-events: none; white-space: nowrap;\n  padding: 5px 8px; border-radius: 8px; font: 500 11.5px/1 var(--vs-audio-player-mono); font-variant-numeric: tabular-nums;\n  color: var(--vs-audio-player-bg); background: var(--vs-audio-player-ink);\n  box-shadow: 0 8px 18px -8px rgba(0,0,0,.45); opacity: 0; will-change: transform, opacity;\n}\n.vs-audio-player-tip em { font-style: normal; opacity: .6; margin-left: 6px; font-family: var(--vs-audio-player-font); letter-spacing: 0; }\n\n/* transport */\n.vs-audio-player-bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; margin-top: 8px; }\n.vs-audio-player-time { font: 400 12px/1 var(--vs-audio-player-mono); font-variant-numeric: tabular-nums; opacity: .55; letter-spacing: -.01em; }\n.vs-audio-player-time:last-child { text-align: right; }\n.vs-audio-player-transport { display: flex; align-items: center; gap: 10px; }\n.vs-audio-player-btn {\n  all: unset; box-sizing: border-box; position: relative; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%;\n  cursor: pointer; color: var(--vs-audio-player-ink); transition: background-color .15s cubic-bezier(.2,0,0,1), transform .35s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-audio-player-btn:hover { background: color-mix(in srgb, var(--vs-audio-player-ink) 7%, transparent); }\n.vs-audio-player-btn:active { transform: scale(.9); }\n.vs-audio-player-btn:focus-visible { box-shadow: 0 0 0 2px var(--vs-audio-player-accent); }\n.vs-audio-player-btn svg { width: 28px; height: 28px; display: block; transition: transform .5s cubic-bezier(.3,1.4,.5,1); }\n.vs-audio-player-btn.is-spun svg { transform: rotate(var(--spin, -360deg)); transition: none; }\n.vs-audio-player-play {\n  all: unset; box-sizing: border-box; position: relative; width: 52px; height: 52px; border-radius: 50%; cursor: pointer;\n  display: grid; place-items: center; color: #fff; background: var(--vs-audio-player-accent);\n  box-shadow: 0 1px 0 rgba(255,255,255,.25) inset, 0 -1px 0 rgba(0,0,0,.12) inset, 0 8px 18px -8px color-mix(in srgb, var(--vs-audio-player-accent) 80%, #000);\n  will-change: transform;\n}\n.vs-audio-player-play:focus-visible { box-shadow: 0 0 0 2px var(--vs-audio-player-bg), 0 0 0 4px var(--vs-audio-player-accent); }\n.vs-audio-player-play svg { grid-area: 1 / 1; width: 20px; height: 20px; transition: transform .45s cubic-bezier(.3,1.5,.5,1), opacity .2s cubic-bezier(.2,0,0,1); }\n.vs-audio-player-play .i-pause { transform: scale(.4) rotate(-90deg); opacity: 0; }\n.vs-audio-player.is-playing .vs-audio-player-play .i-play { transform: scale(.4) rotate(90deg); opacity: 0; }\n.vs-audio-player.is-playing .vs-audio-player-play .i-pause { transform: none; opacity: 1; }\n.vs-audio-player-play .i-spin { position: absolute; inset: -4px; width: auto; height: auto; opacity: 0; transition: opacity .2s; }\n.vs-audio-player.is-loading .vs-audio-player-play .i-spin { opacity: 1; animation: vs-audio-player-spin .9s linear infinite; }\n@keyframes vs-audio-player-spin { to { transform: rotate(360deg); } }\n\n/* chapters */\n.vs-audio-player-chapters { list-style: none; margin: 14px -10px 0; padding: 10px 0 0; border-top: .5px solid color-mix(in srgb, var(--vs-audio-player-ink) 12%, transparent); counter-reset: vsap; }\n.vs-audio-player[data-chapters=\"off\"] .vs-audio-player-chapters { display: none; }\n.vs-audio-player-chapters li { counter-increment: vsap; }\n.vs-audio-player-chapter {\n  all: unset; box-sizing: border-box; position: relative; display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 6px;\n  width: 100%; padding: 9px 10px; border-radius: 10px; cursor: pointer; overflow: hidden; font-size: 14px; line-height: 1.2;\n  transition: background-color .15s cubic-bezier(.2,0,0,1);\n}\n.vs-audio-player-chapter:hover { background: color-mix(in srgb, var(--vs-audio-player-ink) 5%, transparent); }\n.vs-audio-player-chapter:focus-visible { box-shadow: 0 0 0 1.5px var(--vs-audio-player-accent) inset; }\n.vs-audio-player-chapter::before { content: counter(vsap, decimal-leading-zero); font: 400 11px/1 var(--vs-audio-player-mono); opacity: .4; }\n.vs-audio-player-chapter time { font: 400 12px/1 var(--vs-audio-player-mono); font-variant-numeric: tabular-nums; opacity: .45; }\n.vs-audio-player-chapter .vs-audio-player-fill {\n  position: absolute; inset: 0; z-index: -1; transform-origin: 0 50%; transform: scaleX(0);\n  background: color-mix(in srgb, var(--vs-audio-player-accent) 9%, transparent);\n}\n.vs-audio-player-chapter.is-on { color: var(--vs-audio-player-accent); font-weight: 500; }\n.vs-audio-player-chapter.is-on::before, .vs-audio-player-chapter.is-on time { opacity: .8; }\n.vs-audio-player-chapter { isolation: isolate; }\n.vs-audio-player-eq { display: inline-flex; gap: 2px; align-items: flex-end; height: 10px; margin-left: 8px; vertical-align: -1px; opacity: 0; transition: opacity .2s; }\n.vs-audio-player-eq i { width: 2px; height: 100%; border-radius: 1px; background: currentColor; transform-origin: 50% 100%; transform: scaleY(.3); }\n.vs-audio-player.is-playing .vs-audio-player-chapter.is-on .vs-audio-player-eq { opacity: 1; }\n.vs-audio-player.is-playing .vs-audio-player-chapter.is-on .vs-audio-player-eq i { animation: vs-audio-player-eq .9s cubic-bezier(.45,0,.55,1) infinite alternate; }\n.vs-audio-player-eq i:nth-child(2) { animation-delay: -.35s !important; animation-duration: .7s !important; }\n.vs-audio-player-eq i:nth-child(3) { animation-delay: -.6s !important; animation-duration: 1.1s !important; }\n@keyframes vs-audio-player-eq { to { transform: scaleY(1); } }\n\n@media (max-width: 480px) {\n  .vs-audio-player { padding: 16px 16px 10px; }\n  .vs-audio-player-cover { width: 48px; height: 48px; }\n  .vs-audio-player-title { font-size: 17px; }\n  .vs-audio-player-transport { gap: 4px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-audio-player *, .vs-audio-player *::before { transition: none !important; animation: none !important; }\n}\n</style>\n\n<script>\n(() => {\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n  const SPEEDS = [1, 1.25, 1.5, 2, 0.75];\n  const P = 1800; // resolution of the decoded peak envelope\n  const cache = new Map();\n  const fmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, x = String(s % 60).padStart(2, '0'); return h ? `${h}:${String(m).padStart(2, '0')}:${x}` : `${m}:${x}`; };\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  const ICON = {\n    play: '<svg class=\"i-play\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><path d=\"M6 3.8c0-.8.9-1.3 1.6-.9l9 5.4c.7.4.7 1.4 0 1.8l-9 5.4c-.7.4-1.6-.1-1.6-.9z\" fill=\"currentColor\"/></svg>',\n    pause: '<svg class=\"i-pause\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><rect x=\"4.5\" y=\"3.5\" width=\"4\" height=\"13\" rx=\"1.3\" fill=\"currentColor\"/><rect x=\"11.5\" y=\"3.5\" width=\"4\" height=\"13\" rx=\"1.3\" fill=\"currentColor\"/></svg>',\n    spin: '<svg class=\"i-spin\" viewBox=\"0 0 60 60\" aria-hidden=\"true\"><circle cx=\"30\" cy=\"30\" r=\"28\" fill=\"none\" stroke=\"currentColor\" stroke-opacity=\".9\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-dasharray=\"40 160\"/></svg>',\n    skip: d => `<svg viewBox=\"0 0 28 28\" aria-hidden=\"true\"><g style=\"${d > 0 ? 'transform:scaleX(-1);transform-origin:14px 14px' : ''}\"><path d=\"M4.6 14a9.4 9.4 0 1 0 2.9-6.8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"/><path d=\"M8.2 3.3 7.4 7.6l4.3.7\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g><text x=\"14\" y=\"17.6\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"600\" fill=\"currentColor\" style=\"font-family:var(--vs-audio-player-mono);letter-spacing:-.04em\">15</text></svg>`,\n  };\n\n  async function peaksFor(src) {\n    if (cache.has(src)) return cache.get(src);\n    const job = (async () => {\n      const buf = await (await fetch(src)).arrayBuffer();\n      const Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;\n      const audio = await new Ctx(1, 2, 44100).decodeAudioData(buf);\n      const chans = [...Array(audio.numberOfChannels)].map((_, i) => audio.getChannelData(i));\n      const len = chans[0].length, step = len / P, out = new Float32Array(P);\n      for (let i = 0; i < P; i++) {\n        let sum = 0, n = 0; const a = Math.floor(i * step), b = Math.floor((i + 1) * step);\n        for (let j = a; j < b; j += 16) { let v = 0; for (const c of chans) v += c[j]; v /= chans.length; sum += v * v; n++; }\n        out[i] = Math.sqrt(sum / Math.max(1, n));\n      }\n      return { peaks: out, duration: audio.duration };\n    })();\n    cache.set(src, job);\n    return job;\n  }\n\n  function mount(root) {\n    if (root.__vsAudioPlayer) return root.__vsAudioPlayer;\n    const $ = (t, c, h) => { const e = document.createElement(t); if (c) e.className = c; if (h != null) e.innerHTML = h; return e; };\n    const audio = root.querySelector('audio') || new Audio();\n    if (!audio.parentNode) { audio.preload = 'metadata'; audio.src = root.dataset.src; }\n    audio.crossOrigin = audio.crossOrigin || null;\n    const src = audio.currentSrc || audio.src || root.dataset.src;\n\n    // head: now-playing line + speed pill\n    const head = root.querySelector('.vs-audio-player-head');\n    const titles = root.querySelector('.vs-audio-player-titles');\n    const now = $('span', 'vs-audio-player-now'); titles && titles.append(now);\n    const speed = $('button', 'vs-audio-player-speed', '<span>1×</span>'); speed.type = 'button'; speed.setAttribute('aria-label', 'Playback speed, 1×');\n    head && head.append(speed);\n\n    // waveform\n    const wave = $('div', 'vs-audio-player-wave');\n    wave.tabIndex = 0; wave.setAttribute('role', 'slider'); wave.setAttribute('aria-label', 'Seek'); wave.setAttribute('aria-valuemin', '0');\n    const cv = $('canvas'); const ctx = cv.getContext('2d');\n    const line = $('div', 'vs-audio-player-head-line'); const tip = $('div', 'vs-audio-player-tip');\n    wave.append(cv, line, tip);\n\n    // transport\n    const bar = $('div', 'vs-audio-player-bar');\n    const tCur = $('span', 'vs-audio-player-time', '0:00'), tLeft = $('span', 'vs-audio-player-time', '−0:00');\n    const tr = $('div', 'vs-audio-player-transport');\n    const back = $('button', 'vs-audio-player-btn', ICON.skip(-1)); back.type = 'button'; back.setAttribute('aria-label', 'Back 15 seconds'); back.style.setProperty('--spin', '360deg');\n    const play = $('button', 'vs-audio-player-play', ICON.play + ICON.pause + ICON.spin); play.type = 'button'; play.setAttribute('aria-label', 'Play');\n    const fwd = $('button', 'vs-audio-player-btn', ICON.skip(1)); fwd.type = 'button'; fwd.setAttribute('aria-label', 'Forward 15 seconds');\n    tr.append(back, play, fwd); bar.append(tCur, tr, tLeft);\n\n    // chapters\n    const list = root.querySelector('.vs-audio-player-chapters');\n    const chapters = list ? [...list.querySelectorAll('li')].map(li => {\n      const start = parseFloat(li.dataset.start) || 0, title = li.textContent.trim();\n      const b = $('button', 'vs-audio-player-chapter'); b.type = 'button';\n      b.innerHTML = `<span>${title}<span class=\"vs-audio-player-eq\" aria-hidden=\"true\"><i></i><i></i><i></i></span></span><time>${fmt(start)}</time><span class=\"vs-audio-player-fill\" aria-hidden=\"true\"></span>`;\n      b.setAttribute('aria-label', `${title}, starts ${fmt(start)}`);\n      li.textContent = ''; li.append(b);\n      b.addEventListener('click', () => { seek(start, true); if (audio.paused) toggle(true); });\n      return { start, title, b, fill: b.querySelector('.vs-audio-player-fill') };\n    }).sort((a, b) => a.start - b.start) : [];\n    (head || root.firstChild) ? (head ? head.after(wave) : root.prepend(wave)) : root.append(wave);\n    wave.after(bar);\n    if (list) bar.after(list);\n\n    // state\n    let dur = parseFloat(root.dataset.duration) || 0, peaks = null, W = 0, H = 0, dpr = 1, vis = true;\n    let shown = 0, vel = 0, hover = -1, drag = false, raf = 0, last = 0, bornAt = 0, ripple = null, colors = null, chap = -1;\n    const probe = $('i'); probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden'; root.append(probe);\n    const col = v => { probe.style.color = `var(${v})`; return getComputedStyle(probe).color; };\n    const readColors = () => { colors = { acc: col('--vs-audio-player-accent'), wave: col('--vs-audio-player-wave'), ink: col('--vs-audio-player-ink') }; };\n    const barW = () => parseFloat(getComputedStyle(root).getPropertyValue('--vs-audio-player-bar')) || 3;\n\n    // placeholder envelope while decoding (seeded, gently shaped)\n    const fake = new Float32Array(P); { let s = 7; for (let i = 0; i < P; i++) { s = (s * 16807) % 2147483647; fake[i] = .25 + .5 * (s / 2147483647) * (.6 + .4 * Math.sin(i / 90)); } }\n    const fromAttr = root.dataset.peaks ? Float32Array.from(root.dataset.peaks.split(',').map(Number)) : null;\n\n    function levels(n) {\n      const src = peaks || fromAttr || fake, out = new Float32Array(n), k = src.length / n; let mx = 1e-6;\n      for (let i = 0; i < n; i++) { let m = 0; const a = Math.floor(i * k), b = Math.max(a + 1, Math.floor((i + 1) * k)); for (let j = a; j < b; j++) m = Math.max(m, src[j]); out[i] = m; mx = Math.max(mx, m); }\n      const sorted = Float32Array.from(out).sort(), lo = sorted[Math.floor(n * .08)] * .55;\n      for (let i = 0; i < n; i++) out[i] = Math.max(.06, Math.pow(clamp((out[i] - lo) / (mx - lo), 0, 1), 1.35));\n      return out;\n    }\n    let lv = null, gaps = new Set();\n    function layout() {\n      const r = wave.getBoundingClientRect(); if (!r.width) return;\n      dpr = Math.min(2, devicePixelRatio || 1); W = r.width; H = r.height;\n      cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);\n      const bw = barW(), gap = Math.max(1, bw * .66), n = Math.floor((W + gap) / (bw + gap));\n      lv = levels(n); gaps = new Set();\n      if (dur) for (const c of chapters) if (c.start > 0) gaps.add(Math.floor(c.start / dur * n));\n      readColors(); draw();\n    }\n    function draw() {\n      if (!lv || !W) return;\n      const t = performance.now(), bw = barW(), n = lv.length, gap = (W - n * bw) / Math.max(1, n - 1);\n      const mirror = root.dataset.style === 'mirror';\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H);\n      const path = new Path2D(), refl = mirror ? new Path2D() : null;\n      const base = mirror ? H * .72 : H / 2, max = mirror ? H * .7 : H * .96;\n      const loading = !peaks && !fromAttr;\n      for (let i = 0; i < n; i++) {\n        if (gaps.has(i)) continue;\n        let h = lv[i] * max;\n        const age = t - bornAt - i * (RM.matches ? 0 : 4); // entrance: bars rise left to right\n        const e = RM.matches ? 1 : clamp(age / 520, 0, 1); h *= 1 - Math.pow(1 - e, 3);\n        if (ripple) { const a = t - ripple.t, d = Math.abs(i - ripple.i); h *= 1 + .38 * Math.exp(-a / 380) * Math.exp(-Math.pow(a / 13 - d, 2) / 6); }\n        if (loading) h *= .55 + .15 * Math.sin(t / 260 - i / 7);\n        h = Math.max(2, h);\n        const x = i * (bw + gap), r = Math.min(bw / 2, 1.5);\n        if (mirror) { path.roundRect(x, base - h, bw, h, [r, r, 0, 0]); refl.roundRect(x, base + 2, bw, h * .42, [0, 0, r, r]); }\n        else path.roundRect(x, base - h / 2, bw, h, r);\n      }\n      const px = shown * W;\n      const paint = (fill, x0, x1, alpha = 1) => {\n        if (x1 <= x0) return; ctx.save(); ctx.beginPath(); ctx.rect(x0, 0, x1 - x0, H); ctx.clip(); ctx.globalAlpha = alpha; ctx.fillStyle = fill; ctx.fill(path);\n        if (refl) { ctx.globalAlpha = alpha * .32; ctx.fill(refl); } ctx.restore();\n      };\n      paint(colors.wave, 0, W); paint(colors.acc, 0, px);\n      if (hover >= 0 && !drag) hover > px ? paint(colors.acc, px, hover, .32) : paint(colors.wave, hover, px, .9);\n      if (ripple && t - ripple.t > 1600) ripple = null;\n      return t - bornAt < 520 + n * 4 || !!ripple || loading;\n    }\n\n    // time & chapters UI\n    function syncText() {\n      const t = drag ? shown * dur : audio.currentTime;\n      tCur.textContent = fmt(t); tLeft.textContent = '−' + fmt(dur - t);\n      wave.setAttribute('aria-valuemax', String(Math.round(dur))); wave.setAttribute('aria-valuenow', String(Math.round(t)));\n      wave.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(dur)}`);\n      let c = -1; for (let i = 0; i < chapters.length; i++) if (t >= chapters[i].start - .05) c = i;\n      if (c !== chap) {\n        chap = c; chapters.forEach((x, i) => { x.b.classList.toggle('is-on', i === c); x.b.setAttribute('aria-current', String(i === c)); });\n        now.innerHTML = c >= 0 ? `Chapter ${c + 1} · <b>${chapters[c].title}</b>` : '';\n      }\n      chapters.forEach((x, i) => {\n        const end = chapters[i + 1] ? chapters[i + 1].start : dur;\n        x.fill.style.transform = `scaleX(${i === c ? clamp((t - x.start) / Math.max(.01, end - x.start), 0, 1) : i < c ? 0 : 0})`;\n      });\n    }\n    function frame(ts) {\n      raf = 0; const dt = Math.min(.05, (ts - (last || ts)) / 1000); last = ts;\n      const target = dur ? audio.currentTime / dur : 0; let moving = false;\n      if (!drag) {\n        if (RM.matches || Math.abs(target - shown) < .0006 && Math.abs(vel) < .001) { shown = target; vel = 0; }\n        else { const k = 260, c = 2 * Math.sqrt(k) * .92; vel += (-(shown - target) * k - vel * c) * dt; shown += vel * dt; moving = true; }\n      }\n      line.style.transform = `translateX(${shown * W}px)`;\n      const busy = vis ? draw() : false; syncText();\n      if (!audio.paused || moving || busy || drag || hover >= 0) kick(); else last = 0;\n    }\n    const kick = () => { if (!raf) raf = requestAnimationFrame(frame); };\n\n    function seek(t, animate) {\n      if (!dur) return; t = clamp(t, 0, dur);\n      if (animate && lv) ripple = { t: performance.now(), i: Math.round(t / dur * lv.length) };\n      audio.currentTime = t; if (!animate) shown = t / dur; kick();\n    }\n    function toggle(on = audio.paused) {\n      if (on) {\n        document.querySelectorAll('.vs-audio-player').forEach(o => o !== root && o.__vsAudioPlayer && o.__vsAudioPlayer.pause());\n        const p = audio.play(); if (p && p.catch) p.catch(() => {});\n      } else audio.pause();\n    }\n    function pressPop(el) {\n      if (RM.matches) return;\n      el.animate([{ transform: 'scale(1)' }, { transform: 'scale(.86)', offset: .25 }, { transform: 'scale(1.06)', offset: .6 }, { transform: 'scale(1)' }], { duration: 460, easing: 'cubic-bezier(.2,0,0,1)' });\n    }\n    function spin(btn) { btn.classList.add('is-spun'); void btn.offsetWidth; btn.classList.remove('is-spun'); }\n\n    play.addEventListener('click', () => { pressPop(play); toggle(); });\n    back.addEventListener('click', () => { spin(back); seek(audio.currentTime - 15, true); });\n    fwd.addEventListener('click', () => { spin(fwd); seek(audio.currentTime + 15, true); });\n    speed.addEventListener('click', () => {\n      const i = (SPEEDS.indexOf(audio.playbackRate) + 1) % SPEEDS.length; setRate(SPEEDS[i], true);\n    });\n    function setRate(r, anim) {\n      audio.playbackRate = r; audio.defaultPlaybackRate = r;\n      const old = speed.querySelector('span'), nu = $('span', '', `${r}×`); speed.append(nu);\n      speed.classList.toggle('is-fast', r !== 1); speed.setAttribute('aria-label', `Playback speed, ${r}×`);\n      if (anim && !RM.matches && old) {\n        const o = { duration: 420, easing: 'cubic-bezier(.3,1.4,.5,1)', fill: 'forwards' };\n        old.animate([{ transform: 'none', opacity: 1 }, { transform: 'translateY(-110%)', opacity: 0 }], o).onfinish = () => old.remove();\n        nu.animate([{ transform: 'translateY(110%)', opacity: 0 }, { transform: 'none', opacity: 1 }], o);\n      } else if (old) old.remove();\n    }\n\n    // waveform pointer: hover preview, tap/drag to scrub\n    const xOf = e => clamp(e.clientX - wave.getBoundingClientRect().left, 0, W);\n    function showTip(x) {\n      const t = x / W * dur; let c = null; for (const ch of chapters) if (t >= ch.start) c = ch;\n      tip.innerHTML = fmt(t) + (c ? `<em>${c.title}</em>` : '');\n      const tw = tip.offsetWidth; tip.style.transform = `translateX(${clamp(x - tw / 2, -6, W - tw + 6)}px)`; tip.style.opacity = '1';\n    }\n    wave.addEventListener('pointermove', e => { if (!dur) return; hover = xOf(e); if (e.pointerType !== 'touch' || drag) showTip(hover); if (drag) shown = hover / W; kick(); });\n    wave.addEventListener('pointerleave', () => { if (!drag) { hover = -1; tip.style.opacity = '0'; kick(); } });\n    wave.addEventListener('pointerdown', e => {\n      if (!dur || e.button > 0) return; wave.setPointerCapture(e.pointerId); drag = true; root.classList.add('is-scrubbing');\n      hover = xOf(e); shown = hover / W; vel = 0; showTip(hover); kick();\n    });\n    const end = e => {\n      if (!drag) return; drag = false; root.classList.remove('is-scrubbing');\n      seek(shown * dur, true); if (e.pointerType === 'touch') { hover = -1; tip.style.opacity = '0'; }\n    };\n    wave.addEventListener('pointerup', end); wave.addEventListener('pointercancel', end);\n    wave.addEventListener('keydown', e => {\n      const step = e.shiftKey ? 15 : 5, map = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: 30, PageDown: -30 };\n      if (e.key in map) { e.preventDefault(); seek(audio.currentTime + map[e.key], true); }\n      else if (e.key === 'Home') { e.preventDefault(); seek(0, true); }\n      else if (e.key === 'End') { e.preventDefault(); seek(dur - .5, true); }\n    });\n    root.addEventListener('keydown', e => {\n      if (e.target.closest('button') && (e.key === ' ' || e.key === 'Enter')) return;\n      if (e.key === ' ' || e.key === 'k') { e.preventDefault(); toggle(); }\n      else if (e.key === 'j') seek(audio.currentTime - 15, true);\n      else if (e.key === 'l') seek(audio.currentTime + 15, true);\n    });\n\n    // media events\n    const setDur = d => { if (d && isFinite(d) && Math.abs(d - dur) > .01) { dur = d; layout(); syncText(); } };\n    audio.addEventListener('loadedmetadata', () => setDur(audio.duration));\n    audio.addEventListener('play', () => { root.classList.add('is-playing'); play.setAttribute('aria-label', 'Pause'); kick(); });\n    audio.addEventListener('pause', () => { root.classList.remove('is-playing', 'is-loading'); play.setAttribute('aria-label', 'Play'); kick(); });\n    audio.addEventListener('waiting', () => root.classList.add('is-loading'));\n    audio.addEventListener('playing', () => root.classList.remove('is-loading'));\n    audio.addEventListener('ended', () => { root.classList.remove('is-playing'); kick(); });\n    audio.addEventListener('seeked', kick);\n    audio.addEventListener('ratechange', () => { if (!speed.textContent.startsWith(audio.playbackRate + '×')) setRate(audio.playbackRate, false); });\n    if ('mediaSession' in navigator) audio.addEventListener('play', () => {\n      try {\n        const cover = root.querySelector('.vs-audio-player-cover');\n        navigator.mediaSession.metadata = new MediaMetadata({ title: root.querySelector('.vs-audio-player-title')?.textContent || '', artist: root.querySelector('.vs-audio-player-kicker')?.textContent || '', artwork: cover ? [{ src: cover.currentSrc || cover.src, sizes: '640x640' }] : [] });\n        navigator.mediaSession.setActionHandler('seekbackward', () => seek(audio.currentTime - 15, true));\n        navigator.mediaSession.setActionHandler('seekforward', () => seek(audio.currentTime + 15, true));\n      } catch (_) {}\n    });\n\n    new ResizeObserver(layout).observe(wave);\n    new MutationObserver(() => { readColors(); layout(); }).observe(root, { attributes: true, attributeFilter: ['style', 'class', 'data-style'] });\n    new IntersectionObserver(([en]) => { vis = en.isIntersecting; if (vis) kick(); }).observe(root);\n    bornAt = performance.now(); layout(); syncText(); kick();\n    if (src && !fromAttr) peaksFor(src).then(r => { peaks = r.peaks; setDur(r.duration); bornAt = performance.now(); layout(); kick(); }).catch(() => { peaks = fake; kick(); });\n\n    const api = { play: () => toggle(true), pause: () => toggle(false), toggle: () => toggle(), seek: t => seek(t, true), setRate: r => setRate(r, true), audio, refresh: () => { readColors(); layout(); } };\n    root.__vsAudioPlayer = api; return api;\n  }\n  document.querySelectorAll('.vs-audio-player').forEach(mount);\n  window.vsAudioPlayer = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "audio",
      "player",
      "waveform",
      "podcast",
      "chapters",
      "scrubber",
      "media",
      "playback speed"
    ],
    "tech": [
      "CSS",
      "JS",
      "Canvas",
      "Web Audio"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#0d6e66",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/audio-player/index.html",
    "thumbnail": "https://vividsites.app/api/component/audio-player/thumb.jpg",
    "video": "https://vividsites.app/api/component/audio-player/thumb.mp4",
    "page": "https://vividsites.app/c/audio-player"
  }
}