{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vinyl-player",
  "type": "registry:block",
  "title": "Vinyl Record Player",
  "description": "A record-player music widget: the platter spins up, the tonearm swings over and drops before the music starts, then creeps inward with the track. Grab the record to scratch, flick it to spin, switch 33/45 to change speed and pitch.",
  "docs": "Vinyl Record Player was added to public/vividsites/vinyl-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/vinyl-player",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/vinyl-player.html",
      "type": "registry:file",
      "target": "public/vividsites/vinyl-player.html",
      "content": "<!--\nvs-vinyl-player: a record-player music widget. Press play and the platter spins up, the\n  tonearm swings over on a slow spring and drops into the groove, and only then does the music\n  start. While it plays the arm creeps inward with the track, so seeking visibly moves it.\n  Stop and the arm lifts home while the platter coasts down. Grab the record to scratch: it\n  turns under your pointer, the audio follows the groove, and a flick keeps spinning before\n  the motor pulls it back to speed. 33/45 changes the platter speed and, like the real thing,\n  the pitch. The light on the record stays fixed while the label turns.\n\n  Markup:  <div class=\"vs-vinyl-player\" aria-label=\"Record player\">\n             <ol class=\"vs-vinyl-player-tracks\">\n               <li data-src=\"track.mp3\" data-cover=\"label.jpg\" data-artist=\"Artist\" data-side=\"Side A\">Title</li> ...\n             </ol>\n           </div>\n  The deck, arm and controls are built by the script. Space toggles play when the widget is\n  focused; ←/→ on the progress bar seek 5 s.\n  Modifiers: .vs-vinyl-player--walnut, .vs-vinyl-player--graphite (plinth finishes).\n  CSS variables: --vs-vinyl-player-size (deck), -deck, -ink, -accent, -vinyl (record colour),\n    -radius, -font, -mono. Under 640px the panel stacks below the deck.\n  JS: el.__vsVinylPlayer.play() / stop() / next() / prev() / rpm(33|45); vsVinylPlayer.mount(el).\n  Reduced motion: no spin-up, swing or coast; the arm jumps to the groove.\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=Fraunces:opsz,wght@9..144,400&family=DM+Mono:wght@400&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-vinyl-player\" aria-label=\"Record player\">\n  <ol class=\"vs-vinyl-player-tracks\">\n    <li data-src=\"assets/side-a.mp3\" data-cover=\"assets/sleeve-3.webp\" data-artist=\"Marram Quartet\" data-side=\"Side A\">Low Sun</li>\n    <li data-src=\"assets/side-b.mp3\" data-cover=\"assets/sleeve.webp\" data-artist=\"Marram Quartet\" data-side=\"Side B\">Slack Water</li>\n  </ol>\n</div>\n\n<style>\n.vs-vinyl-player {\n  --vs-vinyl-player-size: 340px;                 /* the deck (square) */\n  --vs-vinyl-player-deck: #efe8dc;               /* plinth finish */\n  --vs-vinyl-player-ink: #1d1a17;                /* panel text and buttons */\n  --vs-vinyl-player-accent: #c8553d;             /* play button, progress, power light */\n  --vs-vinyl-player-vinyl: #121110;              /* record colour: try #7a1f1f or rgba(40,30,30,.8) */\n  --vs-vinyl-player-radius: 26px;\n  --vs-vinyl-player-font: \"Fraunces\", Georgia, \"Times New Roman\", serif;\n  --vs-vinyl-player-mono: \"DM Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n\n  position: relative; box-sizing: border-box; display: inline-grid; grid-template-columns: var(--vs-vinyl-player-size) minmax(220px, 260px);\n  align-items: stretch; gap: 26px; padding: 18px 26px 18px 18px; border-radius: var(--vs-vinyl-player-radius);\n  color: var(--vs-vinyl-player-ink); background: var(--vs-vinyl-player-deck); font-family: var(--vs-vinyl-player-mono);\n  box-shadow: 0 1px 0 rgba(255,255,255,.55) inset, 0 -1px 0 rgba(0,0,0,.08) inset, 0 0 0 .5px rgba(0,0,0,.12),\n              0 2px 3px rgba(40,16,8,.12), 0 24px 50px -20px rgba(40,16,8,.45), 0 60px 90px -50px rgba(40,16,8,.5);\n  -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;\n}\n.vs-vinyl-player *, .vs-vinyl-player *::before, .vs-vinyl-player *::after { box-sizing: border-box; }\n.vs-vinyl-player-tracks { display: none; }\n.vs-vinyl-player--walnut { --vs-vinyl-player-deck: #5a3b28; --vs-vinyl-player-ink: #f6ecdf; }\n.vs-vinyl-player--graphite { --vs-vinyl-player-deck: #24262a; --vs-vinyl-player-ink: #eceae6; }\n.vs-vinyl-player--walnut, .vs-vinyl-player--graphite { box-shadow: 0 1px 0 rgba(255,255,255,.12) inset, 0 0 0 .5px rgba(0,0,0,.3), 0 24px 50px -20px rgba(20,8,4,.6), 0 60px 90px -50px rgba(20,8,4,.6); }\n.vs-vinyl-player--walnut { background-image: repeating-linear-gradient(92deg, rgba(0,0,0,.05) 0 2px, transparent 2px 7px), linear-gradient(160deg, rgba(255,255,255,.06), transparent 40%); }\n\n/* deck */\n.vs-vinyl-player-deck { position: relative; width: var(--vs-vinyl-player-size); aspect-ratio: 1; touch-action: none; }\n.vs-vinyl-player-platter {\n  position: absolute; left: 4%; top: 10%; width: 80%; aspect-ratio: 1; border-radius: 50%;\n  background: radial-gradient(circle, #2a2826 0 60%, #3a3835 61%, #1b1a18 63%, #2c2a28 99%);\n  box-shadow: 0 0 0 1px rgba(0,0,0,.25), 0 8px 18px -6px rgba(0,0,0,.5), inset 0 0 0 3px rgba(255,255,255,.05);\n}\n.vs-vinyl-player-disc {\n  position: absolute; inset: 1.5%; border-radius: 50%; cursor: grab; will-change: transform;\n  background:\n    radial-gradient(circle, transparent 0 33%, rgba(255,255,255,.05) 33.4%, transparent 34.2%),\n    radial-gradient(circle, transparent 0 62%, rgba(255,255,255,.07) 62.3%, rgba(0,0,0,.25) 63%, transparent 63.6%),\n    repeating-radial-gradient(circle, rgba(255,255,255,.035) 0 .6px, rgba(0,0,0,.05) .9px 1.6px),\n    radial-gradient(circle, var(--vs-vinyl-player-vinyl) 0 97%, color-mix(in srgb, var(--vs-vinyl-player-vinyl) 70%, #fff) 98.5%, var(--vs-vinyl-player-vinyl) 100%);\n  box-shadow: 0 0 0 1px rgba(0,0,0,.5);\n}\n.vs-vinyl-player.is-scratching .vs-vinyl-player-disc { cursor: grabbing; }\n.vs-vinyl-player-label {\n  position: absolute; left: 50%; top: 50%; width: 34%; aspect-ratio: 1; margin: -17% 0 0 -17%; border-radius: 50%; overflow: hidden;\n  box-shadow: 0 0 0 1px rgba(0,0,0,.35), inset 0 0 0 1px rgba(255,255,255,.1);\n}\n.vs-vinyl-player-label img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity .45s cubic-bezier(.2,0,0,1); -webkit-user-drag: none; }\n.vs-vinyl-player-label::after { /* printed rim on the label */\n  content: \"\"; position: absolute; inset: 7%; border-radius: 50%; border: 1px solid rgba(255,255,255,.35); pointer-events: none;\n}\n.vs-vinyl-player-sheen { /* light that stays put while the record turns */\n  position: absolute; inset: 1.5%; border-radius: 50%; pointer-events: none; mix-blend-mode: screen;\n  background: conic-gradient(from 20deg, transparent 0 8%, rgba(255,255,255,.16) 12%, transparent 17% 50%, rgba(255,255,255,.12) 62%, transparent 67% 100%);\n  -webkit-mask: radial-gradient(circle, transparent 0 17.5%, #000 18.5%); mask: radial-gradient(circle, transparent 0 17.5%, #000 18.5%);\n}\n.vs-vinyl-player-spindle { position: absolute; left: 50%; top: 50%; width: 3.4%; aspect-ratio: 1; margin: -1.7% 0 0 -1.7%; border-radius: 50%;\n  background: radial-gradient(circle at 35% 35%, #f3f0ea, #9b968e 60%, #5b5752); box-shadow: 0 1px 2px rgba(0,0,0,.5); pointer-events: none; }\n.vs-vinyl-player-arm { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; will-change: transform; }\n.vs-vinyl-player-arm .swing { will-change: transform; }\n.vs-vinyl-player-led {\n  position: absolute; left: 6%; bottom: 3.5%; width: 7px; height: 7px; border-radius: 50%; background: color-mix(in srgb, var(--vs-vinyl-player-ink) 25%, transparent);\n  transition: background-color .3s, box-shadow .3s;\n}\n.vs-vinyl-player.is-on .vs-vinyl-player-led { background: var(--vs-vinyl-player-accent); box-shadow: 0 0 10px 2px color-mix(in srgb, var(--vs-vinyl-player-accent) 70%, transparent); }\n\n/* panel */\n.vs-vinyl-player-panel { display: flex; flex-direction: column; min-width: 0; padding: 10px 0 4px; }\n.vs-vinyl-player-kicker { display: flex; align-items: center; gap: 8px; font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; opacity: .55; }\n.vs-vinyl-player-eq { display: inline-flex; gap: 2px; align-items: flex-end; height: 9px; }\n.vs-vinyl-player-eq i { width: 2px; height: 100%; background: currentColor; transform-origin: 50% 100%; transform: scaleY(.25); }\n.vs-vinyl-player.is-playing .vs-vinyl-player-eq i { animation: vs-vinyl-player-eq .8s cubic-bezier(.45,0,.55,1) infinite alternate; }\n.vs-vinyl-player-eq i:nth-child(2) { animation-delay: -.3s !important; animation-duration: .6s !important; }\n.vs-vinyl-player-eq i:nth-child(3) { animation-delay: -.55s !important; animation-duration: 1s !important; }\n@keyframes vs-vinyl-player-eq { to { transform: scaleY(1); } }\n.vs-vinyl-player-title { margin: 14px 0 0; font-family: var(--vs-vinyl-player-font); font-weight: 400; font-size: 34px; line-height: 1; letter-spacing: -.02em; overflow-wrap: anywhere; }\n.vs-vinyl-player-artist { margin-top: 8px; font-family: var(--vs-vinyl-player-font); font-size: 16px; opacity: .6; }\n.vs-vinyl-player-side { margin-top: 6px; font-size: 11px; letter-spacing: .06em; opacity: .45; }\n.vs-vinyl-player-sp { flex: 1; min-height: 18px; }\n.vs-vinyl-player-prog { position: relative; height: 18px; cursor: pointer; touch-action: none; outline: none; }\n.vs-vinyl-player-prog::before { content: \"\"; position: absolute; left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; border-radius: 2px; background: color-mix(in srgb, var(--vs-vinyl-player-ink) 16%, transparent); }\n.vs-vinyl-player-prog i { position: absolute; left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; border-radius: 2px; background: var(--vs-vinyl-player-accent); transform-origin: 0 50%; transform: scaleX(0); }\n.vs-vinyl-player-prog b { position: absolute; top: 50%; left: 0; width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: var(--vs-vinyl-player-accent); transform: scale(0); transition: transform .25s cubic-bezier(.3,1.6,.5,1); }\n.vs-vinyl-player-prog:hover b, .vs-vinyl-player-prog:focus-visible b { transform: scale(1); }\n.vs-vinyl-player-prog:focus-visible b { box-shadow: 0 0 0 3px color-mix(in srgb, var(--vs-vinyl-player-accent) 35%, transparent); }\n.vs-vinyl-player-times { display: flex; justify-content: space-between; font-size: 11px; font-variant-numeric: tabular-nums; opacity: .5; margin-top: 2px; }\n.vs-vinyl-player-row { display: flex; align-items: center; gap: 6px; margin-top: 14px; }\n.vs-vinyl-player-btn {\n  all: unset; box-sizing: border-box; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; cursor: pointer; color: var(--vs-vinyl-player-ink);\n  transition: background-color .15s cubic-bezier(.2,0,0,1), transform .35s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-vinyl-player-btn:hover { background: color-mix(in srgb, var(--vs-vinyl-player-ink) 8%, transparent); }\n.vs-vinyl-player-btn:active { transform: scale(.88); }\n.vs-vinyl-player-btn:focus-visible { box-shadow: 0 0 0 2px var(--vs-vinyl-player-accent); }\n.vs-vinyl-player-btn svg { width: 20px; height: 20px; }\n.vs-vinyl-player-play {\n  all: unset; box-sizing: border-box; position: relative; width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; cursor: pointer;\n  color: #fff; background: var(--vs-vinyl-player-accent);\n  box-shadow: 0 1px 0 rgba(255,255,255,.3) inset, 0 -2px 0 rgba(0,0,0,.14) inset, 0 8px 18px -8px color-mix(in srgb, var(--vs-vinyl-player-accent) 70%, #000);\n  transition: transform .4s cubic-bezier(.3,1.6,.5,1), filter .15s;\n}\n.vs-vinyl-player-play:hover { filter: brightness(1.06); } .vs-vinyl-player-play:active { transform: scale(.92); }\n.vs-vinyl-player-play:focus-visible { box-shadow: 0 0 0 2px var(--vs-vinyl-player-deck), 0 0 0 4px var(--vs-vinyl-player-accent); }\n.vs-vinyl-player-play svg { grid-area: 1 / 1; width: 20px; height: 20px; transition: transform .45s cubic-bezier(.3,1.5,.5,1), opacity .2s; }\n.vs-vinyl-player-play .p2 { opacity: 0; transform: scale(.4) rotate(-90deg); }\n.vs-vinyl-player.is-on .vs-vinyl-player-play .p1 { opacity: 0; transform: scale(.4) rotate(90deg); }\n.vs-vinyl-player.is-on .vs-vinyl-player-play .p2 { opacity: 1; transform: none; }\n.vs-vinyl-player-rpm { margin-left: auto; position: relative; display: flex; padding: 2px; border-radius: 99px; background: color-mix(in srgb, var(--vs-vinyl-player-ink) 8%, transparent); }\n.vs-vinyl-player-rpm button { all: unset; position: relative; z-index: 1; padding: 6px 9px; border-radius: 99px; font-size: 11px; font-variant-numeric: tabular-nums; cursor: pointer; opacity: .55; transition: opacity .2s; }\n.vs-vinyl-player-rpm button[aria-pressed=\"true\"] { opacity: 1; }\n.vs-vinyl-player-rpm button:focus-visible { box-shadow: 0 0 0 1.5px var(--vs-vinyl-player-accent) inset; }\n.vs-vinyl-player-rpm i { position: absolute; top: 2px; bottom: 2px; left: 2px; border-radius: 99px; background: var(--vs-vinyl-player-deck); box-shadow: 0 1px 3px rgba(0,0,0,.18), 0 0 0 .5px rgba(0,0,0,.08); transition: transform .45s cubic-bezier(.3,1.4,.5,1), width .45s cubic-bezier(.3,1.4,.5,1); }\n\n@media (max-width: 640px) {\n  .vs-vinyl-player { grid-template-columns: 1fr; gap: 8px; padding: 14px 16px 16px; --vs-vinyl-player-size: min(100%, 360px); }\n  .vs-vinyl-player-deck { justify-self: center; width: min(100%, 360px); }\n  .vs-vinyl-player-title { font-size: 28px; margin-top: 10px; }\n  .vs-vinyl-player-sp { min-height: 10px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-vinyl-player *, .vs-vinyl-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 clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  const fmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };\n  // deck geometry in the arm's 0–100 viewBox: platter centre, record radius, pivot, stylus at rest\n  const C = { x: 44, y: 50 }, R = 38.8, P = { x: 88, y: 14 }, Q0 = { x: 86.4, y: 72.2 };\n  const stylus = a => { const r = a * Math.PI / 180, dx = Q0.x - P.x, dy = Q0.y - P.y; return { x: P.x + dx * Math.cos(r) - dy * Math.sin(r), y: P.y + dx * Math.sin(r) + dy * Math.cos(r) }; };\n  const angleFor = rad => { let lo = 0, hi = 60; for (let i = 0; i < 30; i++) { const m = (lo + hi) / 2, s = stylus(m); if (Math.hypot(s.x - C.x, s.y - C.y) > rad) lo = m; else hi = m; } return (lo + hi) / 2; };\n  const OUTER = angleFor(R * .93), INNER = angleFor(R * .42), REST = -4;\n  const ARM = `<svg class=\"vs-vinyl-player-arm\" viewBox=\"0 0 100 100\" aria-hidden=\"true\">\n    <defs>\n      <linearGradient id=\"vsvp-m\" x1=\"0\" x2=\"1\"><stop offset=\"0\" stop-color=\"#8d8880\"/><stop offset=\".45\" stop-color=\"#f4f1ec\"/><stop offset=\"1\" stop-color=\"#a39d94\"/></linearGradient>\n      <radialGradient id=\"vsvp-b\" cx=\".35\" cy=\".35\"><stop offset=\"0\" stop-color=\"#fbfaf7\"/><stop offset=\".6\" stop-color=\"#b9b4ac\"/><stop offset=\"1\" stop-color=\"#6f6a63\"/></radialGradient>\n      <filter id=\"vsvp-s\" x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\"><feGaussianBlur stdDeviation=\".9\"/></filter>\n    </defs>\n    <circle cx=\"88\" cy=\"14\" r=\"9.5\" fill=\"rgba(0,0,0,.08)\"/><circle cx=\"88\" cy=\"14\" r=\"8.2\" fill=\"none\" stroke=\"rgba(0,0,0,.18)\" stroke-width=\".4\"/>\n    <g class=\"shadow\" opacity=\".32\" filter=\"url(#vsvp-s)\"><g class=\"swing\">\n      <path d=\"M88 14 L88 63.5 L86.4 72.2\" fill=\"none\" stroke=\"#000\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n      <rect x=\"84.2\" y=\"62.5\" width=\"5.6\" height=\"10\" rx=\"1\" transform=\"rotate(10 87 67)\" fill=\"#000\"/>\n    </g></g>\n    <g class=\"swing\">\n      <rect x=\"85\" y=\"2\" width=\"6\" height=\"9\" rx=\"1.6\" fill=\"#2b2926\"/><rect x=\"85\" y=\"4.5\" width=\"6\" height=\"1\" fill=\"rgba(255,255,255,.18)\"/>\n      <path d=\"M88 14 L88 63.5 L86.4 72.2\" fill=\"none\" stroke=\"url(#vsvp-m)\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n      <rect x=\"84.2\" y=\"62.5\" width=\"5.6\" height=\"10\" rx=\"1\" transform=\"rotate(10 87 67)\" fill=\"#2b2926\"/>\n      <rect x=\"84.9\" y=\"63.3\" width=\"4.2\" height=\"1.4\" rx=\".5\" transform=\"rotate(10 87 67)\" fill=\"rgba(255,255,255,.25)\"/>\n      <path d=\"M83.2 64.5 l-2.6 -.4\" stroke=\"#d8d3cb\" stroke-width=\".8\" stroke-linecap=\"round\" transform=\"rotate(10 87 67)\"/>\n      <circle cx=\"88\" cy=\"14\" r=\"5.2\" fill=\"url(#vsvp-b)\"/><circle cx=\"88\" cy=\"14\" r=\"2\" fill=\"#35322e\"/>\n    </g>\n  </svg>`;\n  const I = {\n    play: '<svg class=\"p1\" viewBox=\"0 0 20 20\"><path d=\"M6.5 4c0-.8.9-1.3 1.6-.8l8.4 5.9c.6.4.6 1.3 0 1.7l-8.4 5.9c-.7.5-1.6 0-1.6-.8z\" fill=\"currentColor\"/></svg>',\n    pause: '<svg class=\"p2\" viewBox=\"0 0 20 20\"><rect x=\"5\" y=\"4\" width=\"3.6\" height=\"12\" rx=\"1.1\" fill=\"currentColor\"/><rect x=\"11.4\" y=\"4\" width=\"3.6\" height=\"12\" rx=\"1.1\" fill=\"currentColor\"/></svg>',\n    prev: '<svg viewBox=\"0 0 20 20\" fill=\"currentColor\"><rect x=\"4\" y=\"5\" width=\"2\" height=\"10\" rx=\".8\"/><path d=\"M16 5.6v8.8c0 .7-.8 1.1-1.4.7L8.3 10.7a.9.9 0 0 1 0-1.4l6.3-4.4c.6-.4 1.4 0 1.4.7z\"/></svg>',\n    next: '<svg viewBox=\"0 0 20 20\" fill=\"currentColor\"><rect x=\"14\" y=\"5\" width=\"2\" height=\"10\" rx=\".8\"/><path d=\"M4 5.6v8.8c0 .7.8 1.1 1.4.7l6.3-4.4a.9.9 0 0 0 0-1.4L5.4 4.9C4.8 4.5 4 4.9 4 5.6z\"/></svg>',\n  };\n\n  function mount(root) {\n    if (root.__vsVinylPlayer) return root.__vsVinylPlayer;\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 tracks = [...root.querySelectorAll('.vs-vinyl-player-tracks li')].map(li => ({ title: li.textContent.trim(), src: li.dataset.src, cover: li.dataset.cover, artist: li.dataset.artist || '', side: li.dataset.side || '' }));\n    root.setAttribute('role', 'region'); root.setAttribute('aria-label', root.getAttribute('aria-label') || 'Record player'); root.tabIndex = root.tabIndex >= 0 ? root.tabIndex : -1;\n\n    const deck = $('div', 'vs-vinyl-player-deck');\n    const uid = 'vsvp' + Math.random().toString(36).slice(2, 7) + '-';\n    deck.innerHTML = `<div class=\"vs-vinyl-player-platter\"><div class=\"vs-vinyl-player-disc\"><div class=\"vs-vinyl-player-label\"><img alt=\"\"><img alt=\"\" style=\"opacity:0\"></div></div><div class=\"vs-vinyl-player-sheen\"></div><div class=\"vs-vinyl-player-spindle\"></div></div>${ARM.replaceAll('vsvp-', uid)}<span class=\"vs-vinyl-player-led\" aria-hidden=\"true\"></span>`;\n    const disc = deck.querySelector('.vs-vinyl-player-disc'), labels = [...deck.querySelectorAll('.vs-vinyl-player-label img')];\n    const swings = [...deck.querySelectorAll('.swing')], shadow = deck.querySelector('.shadow');\n    const panel = $('div', 'vs-vinyl-player-panel');\n    panel.innerHTML = `<div class=\"vs-vinyl-player-kicker\"><span class=\"vs-vinyl-player-eq\" aria-hidden=\"true\"><i></i><i></i><i></i></span><span class=\"st\">On the platter</span></div>\n      <h3 class=\"vs-vinyl-player-title\"></h3><div class=\"vs-vinyl-player-artist\"></div><div class=\"vs-vinyl-player-side\"></div><div class=\"vs-vinyl-player-sp\"></div>\n      <div class=\"vs-vinyl-player-prog\" role=\"slider\" tabindex=\"0\" aria-label=\"Seek\" aria-valuemin=\"0\"><i></i><b></b></div>\n      <div class=\"vs-vinyl-player-times\"><span class=\"cur\">0:00</span><span class=\"dur\">0:00</span></div>\n      <div class=\"vs-vinyl-player-row\"></div>`;\n    const row = panel.querySelector('.vs-vinyl-player-row');\n    const mk = (cls, label, html) => { const b = $('button', cls, html); b.type = 'button'; b.setAttribute('aria-label', label); return b; };\n    const bPrev = mk('vs-vinyl-player-btn', 'Previous track', I.prev), bPlay = mk('vs-vinyl-player-play', 'Play', I.play + I.pause), bNext = mk('vs-vinyl-player-btn', 'Next track', I.next);\n    const rpm = $('div', 'vs-vinyl-player-rpm', '<i></i><button type=\"button\" aria-pressed=\"true\" data-r=\"33\">33</button><button type=\"button\" aria-pressed=\"false\" data-r=\"45\">45</button>');\n    rpm.setAttribute('role', 'group'); rpm.setAttribute('aria-label', 'Speed, rpm');\n    row.append(bPrev, bPlay, bNext, rpm);\n    root.append(deck, panel);\n    const prog = panel.querySelector('.vs-vinyl-player-prog'), fillEl = prog.querySelector('i'), knob = prog.querySelector('b');\n    const tCur = panel.querySelector('.cur'), tDur = panel.querySelector('.dur'), stEl = panel.querySelector('.st');\n\n    const audio = new Audio(); audio.preload = 'metadata'; audio.preservesPitch = false; audio.mozPreservesPitch = false; audio.webkitPreservesPitch = false;\n    let ti = 0, on = false, landed = false, angle = 0, omega = 0, arm = REST, armV = 0, raf = 0, last = 0, speed = 33, dropAt = 0, labelTop = 0, vis = true;\n    let drag = null;\n    const degPerSec = () => (speed === 45 ? 45 : 100 / 3) * 6;\n\n    function load(i, keepOn) {\n      ti = (i + tracks.length) % tracks.length; const t = tracks[ti];\n      audio.src = t.src; audio.playbackRate = speed === 45 ? 1.35 : 1;\n      panel.querySelector('.vs-vinyl-player-title').textContent = t.title;\n      panel.querySelector('.vs-vinyl-player-artist').textContent = t.artist;\n      panel.querySelector('.vs-vinyl-player-side').textContent = (t.side ? t.side + ' · ' : '') + `${ti + 1} / ${tracks.length}`;\n      const nextImg = labels[1 - labelTop]; nextImg.src = t.cover || ''; nextImg.style.opacity = '1'; labels[labelTop].style.opacity = '0'; labelTop = 1 - labelTop;\n      landed = false; if (keepOn) { dropAt = performance.now() + 650; audio.pause(); }\n      paintTime(); kick();\n    }\n    function setOn(v) {\n      on = v; root.classList.toggle('is-on', v); bPlay.setAttribute('aria-label', v ? 'Stop' : 'Play');\n      if (v) { dropAt = performance.now() + (RM.matches ? 0 : 700); landed = false; stEl.textContent = 'Cueing'; }\n      else { audio.pause(); landed = false; root.classList.remove('is-playing'); stEl.textContent = 'On the platter'; }\n      kick();\n    }\n    const prog01 = () => audio.duration ? clamp(audio.currentTime / audio.duration, 0, 1) : 0;\n    function paintTime() {\n      const p = prog01(); fillEl.style.transform = `scaleX(${p})`; knob.style.left = (p * 100) + '%';\n      tCur.textContent = fmt(audio.currentTime); tDur.textContent = fmt(audio.duration || 0);\n      prog.setAttribute('aria-valuemax', String(Math.round(audio.duration || 0))); prog.setAttribute('aria-valuenow', String(Math.round(audio.currentTime))); prog.setAttribute('aria-valuetext', `${fmt(audio.currentTime)} of ${fmt(audio.duration || 0)}`);\n    }\n    function frame(ts) {\n      raf = 0; const dt = Math.min(.05, (ts - (last || ts)) / 1000); last = ts;\n      // platter: quick spin-up, slow coast down; a scratch overrides it\n      const target = on ? degPerSec() : 0;\n      if (!drag) {\n        if (RM.matches) omega = target;\n        else if (omega < target) omega += (target - omega) * (1 - Math.exp(-dt / .32));\n        else omega = Math.max(target, omega - Math.max(110, (omega - target) * 2.2) * dt);\n        angle = (angle + omega * dt) % 360;\n      }\n      // tonearm: swing to the groove for the current position, rest when stopped\n      const armT = on ? OUTER + (INNER - OUTER) * prog01() : REST;\n      if (RM.matches) { arm = armT; armV = 0; } else { const k = on && landed ? 90 : 26, c = 2 * Math.sqrt(k) * .9; armV += (-(arm - armT) * k - armV * c) * dt; arm += armV * dt; }\n      if (on && !landed && performance.now() >= dropAt && Math.abs(arm - armT) < .6 && Math.abs(armV) < 4 && omega > target * .8) {\n        landed = true; const p = audio.play(); p && p.catch(() => setOn(false));\n      }\n      const lift = on && landed ? 0 : clamp(Math.abs(arm - REST) > .5 ? 1 : 0, 0, 1);\n      disc.style.transform = `rotate(${angle.toFixed(2)}deg)`;\n      swings.forEach(s => s.setAttribute('transform', `rotate(${arm.toFixed(3)} 88 14)`));\n      shadow.setAttribute('transform', `translate(${(1.2 + lift * 1.6).toFixed(2)} ${(1.8 + lift * 2.4).toFixed(2)})`);\n      if (vis) paintTime();\n      const moving = on || omega > .01 || Math.abs(arm - armT) > .01 || Math.abs(armV) > .01 || drag;\n      if (moving) raf = requestAnimationFrame(frame); else last = 0;\n    }\n    const kick = () => { if (!raf) raf = requestAnimationFrame(frame); };\n\n    // scratch: grab the record and turn it; the audio follows the groove\n    const center = () => { const r = disc.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; };\n    const ang = e => { const c = center(); return Math.atan2(e.clientY - c.y, e.clientX - c.x) * 180 / Math.PI; };\n    disc.addEventListener('pointerdown', e => {\n      if (e.button > 0) return; disc.setPointerCapture(e.pointerId); drag = { a: ang(e), t: performance.now(), w: 0 }; root.classList.add('is-scratching'); kick();\n    });\n    disc.addEventListener('pointermove', e => {\n      if (!drag) return; let a = ang(e), d = a - drag.a; if (d > 180) d -= 360; if (d < -180) d += 360;\n      const now = performance.now(), dt = Math.max(1, now - drag.t) / 1000;\n      angle = (angle + d + 360) % 360; drag.w = drag.w * .6 + (d / dt) * .4; drag.a = a; drag.t = now;\n      if (on && landed && audio.duration) audio.currentTime = clamp(audio.currentTime + d / degPerSec() * (audio.playbackRate || 1), 0, audio.duration - .05);\n      kick();\n    });\n    const endDrag = () => { if (!drag) return; omega = clamp(drag.w, -900, 1400); drag = null; root.classList.remove('is-scratching'); kick(); };\n    disc.addEventListener('pointerup', endDrag); disc.addEventListener('pointercancel', endDrag);\n\n    // controls\n    bPlay.addEventListener('click', () => setOn(!on));\n    bNext.addEventListener('click', () => { load(ti + 1, on); if (on) { audio.currentTime = 0; } });\n    bPrev.addEventListener('click', () => { if (audio.currentTime > 3) { audio.currentTime = 0; kick(); } else load(ti - 1, on); });\n    rpm.addEventListener('click', e => {\n      const b = e.target.closest('button'); if (!b) return; speed = +b.dataset.r; audio.playbackRate = speed === 45 ? 1.35 : 1;\n      rpm.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b))); placeRpm(); kick();\n    });\n    function placeRpm() { const b = rpm.querySelector('[aria-pressed=\"true\"]'), i = rpm.querySelector('i'); if (!b.offsetWidth) return; i.style.width = b.offsetWidth + 'px'; i.style.transform = `translateX(${b.offsetLeft - 2}px)`; }\n    const seekTo = e => { const r = prog.getBoundingClientRect(); if (audio.duration) { audio.currentTime = clamp((e.clientX - r.left) / r.width, 0, 1) * audio.duration; kick(); } };\n    let pdrag = false;\n    prog.addEventListener('pointerdown', e => { pdrag = true; prog.setPointerCapture(e.pointerId); seekTo(e); });\n    prog.addEventListener('pointermove', e => pdrag && seekTo(e));\n    prog.addEventListener('pointerup', () => pdrag = false); prog.addEventListener('pointercancel', () => pdrag = false);\n    prog.addEventListener('keydown', e => { const d = { ArrowRight: 5, ArrowLeft: -5 }[e.key]; if (d && audio.duration) { e.preventDefault(); audio.currentTime = clamp(audio.currentTime + d, 0, audio.duration); kick(); } });\n    root.addEventListener('keydown', e => { if (e.key === ' ' && !e.target.closest('button')) { e.preventDefault(); setOn(!on); } });\n    audio.addEventListener('playing', () => { root.classList.add('is-playing'); stEl.textContent = 'Now spinning'; });\n    audio.addEventListener('pause', () => root.classList.remove('is-playing'));\n    audio.addEventListener('loadedmetadata', () => { paintTime(); kick(); });\n    audio.addEventListener('ended', () => { if (ti < tracks.length - 1) load(ti + 1, true); else { setOn(false); audio.currentTime = 0; } });\n    new IntersectionObserver(([en]) => { vis = en.isIntersecting; }).observe(root);\n    new ResizeObserver(placeRpm).observe(rpm);\n\n    load(0, false); labels[0].style.opacity = '1'; labels[1].style.opacity = '0'; labelTop = 0; labels[0].src = tracks[0] ? tracks[0].cover : '';\n    swings.forEach(s => s.setAttribute('transform', `rotate(${REST} 88 14)`)); kick();\n    const api = { play: () => setOn(true), stop: () => setOn(false), next: () => bNext.click(), prev: () => bPrev.click(), rpm: r => rpm.querySelector(`[data-r=\"${r}\"]`)?.click(), audio, get angle() { return angle; } };\n    root.__vsVinylPlayer = api; return api;\n  }\n  document.querySelectorAll('.vs-vinyl-player').forEach(mount);\n  window.vsVinylPlayer = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "vinyl",
      "record player",
      "turntable",
      "music",
      "audio",
      "player",
      "tonearm",
      "widget"
    ],
    "tech": [
      "CSS",
      "JS",
      "SVG"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#c8553d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/vinyl-player/index.html",
    "thumbnail": "https://vividsites.app/api/component/vinyl-player/thumb.jpg",
    "video": "https://vividsites.app/api/component/vinyl-player/thumb.mp4",
    "page": "https://vividsites.app/c/vinyl-player"
  }
}