{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "video-player",
  "type": "registry:block",
  "title": "Chaptered Video Player",
  "description": "Custom controls for a native video: a chapter-segmented scrubber with real frame previews on hover, an expanding volume slider, a glass speed menu, picture-in-picture and full screen. Auto-hiding chrome, double-tap to skip on touch and YouTube-style keys.",
  "docs": "Chaptered Video Player was added to public/vividsites/video-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/video-player",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/video-player.html",
      "type": "registry:file",
      "target": "public/vividsites/video-player.html",
      "content": "<!--\nvs-video-player: custom controls for a native <video>. The scrubber is split into chapter\n  segments; hovering lifts it and shows a preview card with a real frame from that moment\n  (captured in the background from a hidden twin of the video), the time and the chapter.\n  Dragging scrubs live. Volume expands on hover, speed opens a small glass menu, and there is\n  picture-in-picture and full screen. Controls fade out after 2.4 s of stillness while playing.\n  Mouse: click the picture to play/pause, double-click for full screen.\n  Touch: tap shows/hides the controls, double-tap the left or right half to skip 10 s.\n  Keys (player focused): Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F full screen,\n  0–9 jump to 0–90 %, Home/End.\n\n  Markup:  <div class=\"vs-video-player\" data-chapters=\"0 Intro; 12.5 Part two; 1:04 Credits\">\n             <video src=\"film.mp4\" poster=\"poster.jpg\" preload=\"metadata\" playsinline></video>\n           </div>\n  Attributes on .vs-video-player:\n    data-chapters   \"seconds-or-m:ss Title; ...\" separated by semicolons (optional)\n    data-previews   \"off\" skips the frame previews (they need a same-origin or CORS video)\n  CSS variables: --vs-video-player-accent, --vs-video-player-ink, --vs-video-player-radius,\n    --vs-video-player-font. The player keeps a 16:9 box; override aspect-ratio for other shapes.\n  JS: el.__vsVideoPlayer.play() / pause() / seek(seconds) / previews(bool); vsVideoPlayer.mount(el).\n  Reduced motion: no pops or bubbles; the spinner turns slowly.\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=Inter+Tight:wght@400;500&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-video-player\" aria-label=\"Showreel\" data-chapters=\"0 Ember dunes; 4.4 Lavender shore; 8.8 Salt flat at night; 18.2 Storm light; 22.6 Glass\">\n  <video src=\"assets/reel.mp4\" poster=\"assets/poster.jpg\" preload=\"metadata\" playsinline></video>\n</div>\n\n<style>\n.vs-video-player {\n  --vs-video-player-accent: #ff6a3d;           /* played track, volume fill, focus rings */\n  --vs-video-player-ink: #ffffff;              /* icons and text over the video */\n  --vs-video-player-radius: 18px;\n  --vs-video-player-font: \"Inter Tight\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n\n  position: relative; box-sizing: border-box; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; isolation: isolate;\n  border-radius: var(--vs-video-player-radius); background: #000; color: var(--vs-video-player-ink);\n  font-family: var(--vs-video-player-font); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.1), 0 30px 80px -30px rgba(0,0,0,.8), 0 10px 24px -12px rgba(0,0,0,.5);\n  user-select: none; -webkit-user-select: none; outline: none;\n}\n.vs-video-player *, .vs-video-player *::before, .vs-video-player *::after { box-sizing: border-box; }\n.vs-video-player:fullscreen { border-radius: 0; box-shadow: none; aspect-ratio: auto; }\n.vs-video-player > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }\n.vs-video-player:fullscreen > video { object-fit: contain; }\n.vs-video-player.is-idle { cursor: none; }\n.vs-video-player:focus-visible { box-shadow: 0 0 0 2px #000, 0 0 0 4px var(--vs-video-player-accent); }\n\n/* scrim + chrome visibility */\n.vs-video-player-scrim {\n  position: absolute; left: 0; right: 0; bottom: 0; height: 46%; pointer-events: none;\n  background: linear-gradient(to top, rgba(0,0,0,.72), rgba(0,0,0,.38) 38%, rgba(0,0,0,.12) 70%, transparent);\n  transition: opacity .4s cubic-bezier(.2,0,0,1);\n}\n.vs-video-player-ui {\n  position: absolute; left: 0; right: 0; bottom: 0; padding: 0 14px 10px;\n  transition: opacity .35s cubic-bezier(.2,0,0,1), transform .5s cubic-bezier(.16,1,.3,1);\n}\n.vs-video-player.is-idle .vs-video-player-ui { opacity: 0; transform: translateY(10px); pointer-events: none; }\n.vs-video-player.is-idle .vs-video-player-scrim { opacity: 0; }\n\n/* scrubber */\n.vs-video-player-track {\n  position: relative; height: 22px; display: flex; align-items: center; cursor: pointer; touch-action: none; outline: none;\n}\n.vs-video-player-rail { position: relative; flex: 1; height: 4px; display: flex; gap: 3px; transform-origin: 50% 50%; transition: transform .28s cubic-bezier(.3,1.5,.5,1); }\n.vs-video-player-track:hover .vs-video-player-rail, .vs-video-player.is-scrubbing .vs-video-player-rail, .vs-video-player-track:focus-visible .vs-video-player-rail { transform: scaleY(1.75); }\n.vs-video-player-seg { position: relative; height: 100%; border-radius: 3px; overflow: hidden; background: rgba(255,255,255,.22); }\n.vs-video-player-seg i { position: absolute; inset: 0; transform-origin: 0 50%; transform: scaleX(0); will-change: transform; }\n.vs-video-player-seg .b { background: rgba(255,255,255,.3); }\n.vs-video-player-seg .h { background: rgba(255,255,255,.45); }\n.vs-video-player-seg .p { background: var(--vs-video-player-accent); }\n.vs-video-player-seg.is-hover { transform: scaleY(1.35); transition: transform .2s cubic-bezier(.3,1.5,.5,1); }\n.vs-video-player-knob {\n  position: absolute; left: 0; top: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; pointer-events: none;\n  background: var(--vs-video-player-accent); box-shadow: 0 0 0 .5px rgba(0,0,0,.2), 0 2px 6px rgba(0,0,0,.4);\n  scale: 0; will-change: transform; transition: scale .25s cubic-bezier(.3,1.6,.5,1);\n}\n@media (hover: none) { .vs-video-player-knob { scale: .8; } }\n.vs-video-player-track:focus-visible .vs-video-player-knob { box-shadow: 0 0 0 3px rgba(255,255,255,.9); }\n\n/* scrub preview */\n.vs-video-player-preview {\n  position: absolute; bottom: 30px; left: 0; pointer-events: none; opacity: 0; transform-origin: 50% 100%; will-change: transform, opacity;\n  display: grid; justify-items: center; gap: 6px;\n}\n.vs-video-player-frame {\n  width: 176px; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: #111;\n  box-shadow: 0 0 0 1.5px rgba(255,255,255,.9), 0 14px 30px -10px rgba(0,0,0,.7);\n}\n.vs-video-player-frame canvas { width: 100%; height: 100%; display: block; }\n.vs-video-player-preview[data-frames=\"off\"] .vs-video-player-frame, .vs-video-player-preview.no-frame .vs-video-player-frame { display: none; }\n.vs-video-player-ptext { display: grid; justify-items: center; gap: 3px; text-shadow: 0 1px 8px rgba(0,0,0,.6); }\n.vs-video-player-ptext b { font-size: 12.5px; font-weight: 500; letter-spacing: -.005em; }\n.vs-video-player-ptext span { font-size: 12px; font-variant-numeric: tabular-nums; opacity: .85; }\n\n/* control row */\n.vs-video-player-row { display: flex; align-items: center; gap: 2px; height: 44px; margin-top: 2px; }\n.vs-video-player-btn {\n  all: unset; box-sizing: border-box; position: relative; width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; cursor: pointer; flex: none;\n  color: var(--vs-video-player-ink); transition: background-color .15s cubic-bezier(.2,0,0,1), transform .35s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-video-player-btn:hover { background: rgba(255,255,255,.12); }\n.vs-video-player-btn:active { transform: scale(.88); }\n.vs-video-player-btn:focus-visible { box-shadow: 0 0 0 2px var(--vs-video-player-accent); }\n.vs-video-player-btn svg { width: 22px; height: 22px; display: block; grid-area: 1 / 1; filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); transition: transform .4s cubic-bezier(.3,1.5,.5,1), opacity .18s cubic-bezier(.2,0,0,1); }\n.vs-video-player-btn .alt { opacity: 0; transform: scale(.5) rotate(-60deg); }\n.vs-video-player-btn.is-alt .main { opacity: 0; transform: scale(.5) rotate(60deg); }\n.vs-video-player-btn.is-alt .alt { opacity: 1; transform: none; }\n.vs-video-player-time { margin: 0 8px; font-size: 13px; font-variant-numeric: tabular-nums; letter-spacing: -.005em; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.4); }\n.vs-video-player-time span { opacity: .6; }\n.vs-video-player-chap { flex: 1; min-width: 0; font-size: 13px; opacity: .8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-shadow: 0 1px 4px rgba(0,0,0,.4); }\n.vs-video-player-chap::before { content: \"·\"; margin-right: 8px; opacity: .6; }\n.vs-video-player-chap:empty::before { content: none; }\n\n/* volume */\n.vs-video-player-vol { display: flex; align-items: center; }\n.vs-video-player-vslider {\n  position: relative; width: 0; height: 40px; overflow: hidden; cursor: pointer; touch-action: none; outline: none;\n  transition: width .45s cubic-bezier(.16,1,.3,1);\n}\n.vs-video-player-vol:hover .vs-video-player-vslider, .vs-video-player-vol:focus-within .vs-video-player-vslider, .vs-video-player.is-vdrag .vs-video-player-vslider { width: 78px; }\n.vs-video-player-vslider::before, .vs-video-player-vslider i {\n  content: \"\"; position: absolute; left: 6px; right: 10px; top: 50%; height: 4px; margin-top: -2px; border-radius: 3px; background: rgba(255,255,255,.28);\n}\n.vs-video-player-vslider i { right: auto; width: 62px; background: var(--vs-video-player-ink); transform-origin: 0 50%; }\n.vs-video-player-vslider b {\n  position: absolute; top: 50%; left: 6px; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; background: var(--vs-video-player-ink);\n  box-shadow: 0 1px 4px rgba(0,0,0,.4);\n}\n.vs-video-player-vslider:focus-visible b { box-shadow: 0 0 0 3px var(--vs-video-player-accent); }\n.vs-video-player-vol .w1, .vs-video-player-vol .w2 { transition: opacity .2s; }\n\n/* speed menu */\n.vs-video-player-menu {\n  position: absolute; right: 12px; bottom: 62px; min-width: 150px; padding: 6px; border-radius: 14px; transform-origin: 85% 100%;\n  background: rgba(22,22,24,.72); -webkit-backdrop-filter: blur(20px) saturate(1.6); backdrop-filter: blur(20px) saturate(1.6);\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.14) inset, 0 20px 40px -12px rgba(0,0,0,.6);\n  opacity: 0; transform: translateY(8px) scale(.94); pointer-events: none;\n  transition: opacity .18s cubic-bezier(.2,0,0,1), transform .4s cubic-bezier(.3,1.4,.5,1);\n}\n.vs-video-player-menu.is-open { opacity: 1; transform: none; pointer-events: auto; }\n.vs-video-player-menu p { margin: 4px 10px 6px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; opacity: .5; }\n.vs-video-player-menu button {\n  all: unset; box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 8px 10px; border-radius: 9px;\n  font-size: 13px; font-variant-numeric: tabular-nums; cursor: pointer;\n}\n.vs-video-player-menu button:hover, .vs-video-player-menu button:focus-visible { background: rgba(255,255,255,.1); }\n.vs-video-player-menu button[aria-checked=\"true\"]::after { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--vs-video-player-accent); }\n\n/* centre: big play, feedback bubble, spinner */\n.vs-video-player-big {\n  all: unset; position: absolute; left: 50%; top: 50%; width: 84px; height: 84px; margin: -42px 0 0 -42px; border-radius: 50%; cursor: pointer;\n  display: grid; place-items: center; color: #fff;\n  background: rgba(255,255,255,.14); -webkit-backdrop-filter: blur(14px) saturate(1.5); backdrop-filter: blur(14px) saturate(1.5);\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.35) inset, 0 1px 0 rgba(255,255,255,.3) inset, 0 20px 40px -14px rgba(0,0,0,.6);\n  transition: opacity .3s cubic-bezier(.2,0,0,1), transform .55s cubic-bezier(.3,1.4,.5,1), background-color .2s;\n}\n.vs-video-player-big:hover { background: rgba(255,255,255,.22); transform: scale(1.06); }\n.vs-video-player-big:active { transform: scale(.95); }\n.vs-video-player-big:focus-visible { box-shadow: 0 0 0 3px var(--vs-video-player-accent); }\n.vs-video-player-big svg { width: 30px; height: 30px; margin-left: 4px; }\n.vs-video-player.is-started .vs-video-player-big { opacity: 0; transform: scale(1.35); pointer-events: none; }\n.vs-video-player-bubble {\n  position: absolute; left: 50%; top: 50%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 50%; pointer-events: none;\n  display: grid; place-items: center; background: rgba(0,0,0,.45); opacity: 0;\n  font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums;\n}\n.vs-video-player-bubble svg { width: 26px; height: 26px; }\n.vs-video-player-tap {\n  position: absolute; top: 0; bottom: 0; width: 38%; pointer-events: none; display: grid; place-items: center; opacity: 0;\n  font-size: 13px; font-weight: 500; background: radial-gradient(closest-side, rgba(255,255,255,.18), rgba(255,255,255,0));\n}\n.vs-video-player-tap.l { left: 0; border-radius: 0 50% 50% 0 / 0 50% 50% 0; }\n.vs-video-player-tap.r { right: 0; border-radius: 50% 0 0 50% / 50% 0 0 50%; }\n.vs-video-player-spin {\n  position: absolute; left: 50%; top: 50%; width: 48px; height: 48px; margin: -24px 0 0 -24px; pointer-events: none; opacity: 0; transition: opacity .2s .15s;\n}\n.vs-video-player.is-waiting .vs-video-player-spin { opacity: 1; }\n.vs-video-player-spin svg { width: 100%; height: 100%; animation: vs-video-player-spin 1s linear infinite; }\n@keyframes vs-video-player-spin { to { transform: rotate(360deg); } }\n\n@media (max-width: 560px) {\n  .vs-video-player { --vs-video-player-radius: 14px; }\n  .vs-video-player-ui { padding: 0 8px 4px; }\n  .vs-video-player-row { height: 40px; }\n  .vs-video-player-btn { width: 36px; height: 36px; }\n  .vs-video-player-btn svg { width: 20px; height: 20px; }\n  .vs-video-player-chap, .vs-video-player [data-hide-sm] { display: none; }\n  .vs-video-player-row .vs-video-player-time { flex: 1; margin: 0 4px; font-size: 12px; }\n  .vs-video-player-big { width: 64px; height: 64px; margin: -32px 0 0 -32px; }\n  .vs-video-player-big svg { width: 24px; height: 24px; }\n  .vs-video-player-frame { width: 128px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-video-player *, .vs-video-player *::before { transition-duration: 0s !important; animation-duration: 3s !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)); const m = Math.floor(s / 60), h = Math.floor(m / 60); return (h ? h + ':' + String(m % 60).padStart(2, '0') : m) + ':' + String(s % 60).padStart(2, '0'); };\n  const I = (d, cls = 'main') => `<svg class=\"${cls}\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">${d}</svg>`;\n  const S = 'fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"';\n  const ICON = {\n    play: '<path d=\"M7.5 4.9c0-.9 1-1.5 1.8-1l10 6.1c.7.5.7 1.5 0 2l-10 6.1c-.8.5-1.8-.1-1.8-1z\" fill=\"currentColor\"/>',\n    pause: '<rect x=\"5.5\" y=\"4\" width=\"4.6\" height=\"16\" rx=\"1.4\" fill=\"currentColor\"/><rect x=\"13.9\" y=\"4\" width=\"4.6\" height=\"16\" rx=\"1.4\" fill=\"currentColor\"/>',\n    vol: `<path d=\"M4 9.5h3l4.5-4v13L7 14.5H4z\" fill=\"currentColor\"/><path class=\"w1\" d=\"M15 9.2a4 4 0 0 1 0 5.6\" ${S}/><path class=\"w2\" d=\"M17.8 6.6a7.6 7.6 0 0 1 0 10.8\" ${S}/>`,\n    mute: `<path d=\"M4 9.5h3l4.5-4v13L7 14.5H4z\" fill=\"currentColor\"/><path d=\"m15.5 9.5 5 5m0-5-5 5\" ${S}/>`,\n    speed: `<path d=\"M4.2 16.5a8.5 8.5 0 1 1 15.6 0\" ${S}/><path d=\"m12 13.5 3.8-4.2\" ${S}/><circle cx=\"12\" cy=\"13.5\" r=\"1.6\" fill=\"currentColor\"/>`,\n    pip: `<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2.5\" ${S}/><rect x=\"12\" y=\"11.5\" width=\"6.5\" height=\"5\" rx=\"1.2\" fill=\"currentColor\"/>`,\n    fs: `<path d=\"M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15\" ${S}/>`,\n    fsx: `<path d=\"M9 4v3.5A1.5 1.5 0 0 1 7.5 9H4M20 9h-3.5A1.5 1.5 0 0 1 15 7.5V4M15 20v-3.5a1.5 1.5 0 0 1 1.5-1.5H20M4 15h3.5A1.5 1.5 0 0 1 9 16.5V20\" ${S}/>`,\n    back: `<path d=\"M4.5 12a7.5 7.5 0 1 0 2.3-5.4\" ${S}/><path d=\"M6.6 2.8 6.3 6.8l4 .4\" ${S}/>`,\n  };\n\n  function mount(root) {\n    if (root.__vsVideoPlayer) return root.__vsVideoPlayer;\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 btn = (label, html, extra = '') => { const b = $('button', 'vs-video-player-btn ' + extra, html); b.type = 'button'; b.setAttribute('aria-label', label); return b; };\n    const video = root.querySelector('video');\n    video.removeAttribute('controls'); video.playsInline = true; video.setAttribute('playsinline', '');\n    root.tabIndex = root.tabIndex >= 0 ? root.tabIndex : 0;\n    root.setAttribute('role', root.getAttribute('role') || 'region'); root.setAttribute('aria-label', root.getAttribute('aria-label') || 'Video player');\n\n    const chapters = (root.dataset.chapters || '').split(';').map(s => s.trim()).filter(Boolean).map(s => {\n      const m = s.match(/^([\\d.:]+)\\s+(.*)$/); if (!m) return null;\n      const t = m[1].split(':').reduce((a, v) => a * 60 + parseFloat(v), 0); return { start: t, title: m[2] };\n    }).filter(Boolean).sort((a, b) => a.start - b.start);\n\n    // chrome\n    const scrim = $('div', 'vs-video-player-scrim');\n    const big = $('button', 'vs-video-player-big', I(ICON.play)); big.type = 'button'; big.setAttribute('aria-label', 'Play video');\n    const bubble = $('div', 'vs-video-player-bubble'); bubble.setAttribute('aria-hidden', 'true');\n    const tapL = $('div', 'vs-video-player-tap l', '−10s'), tapR = $('div', 'vs-video-player-tap r', '+10s');\n    const spin = $('div', 'vs-video-player-spin', '<svg viewBox=\"0 0 48 48\"><circle cx=\"24\" cy=\"24\" r=\"20\" fill=\"none\" stroke=\"rgba(255,255,255,.9)\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-dasharray=\"36 140\"/></svg>');\n    const live = $('div'); live.setAttribute('aria-live', 'polite'); live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)';\n    const ui = $('div', 'vs-video-player-ui');\n    const track = $('div', 'vs-video-player-track'); track.tabIndex = 0; track.setAttribute('role', 'slider'); track.setAttribute('aria-label', 'Seek');\n    const rail = $('div', 'vs-video-player-rail'); const knob = $('div', 'vs-video-player-knob');\n    const preview = $('div', 'vs-video-player-preview', '<div class=\"vs-video-player-frame\"><canvas width=\"352\" height=\"198\"></canvas></div><div class=\"vs-video-player-ptext\"><b></b><span></span></div>');\n    track.append(rail, knob, preview);\n    const row = $('div', 'vs-video-player-row');\n    const bPlay = btn('Play', I(ICON.play) + I(ICON.pause, 'alt'));\n    const bBack = btn('Back 10 seconds', I(ICON.back)); bBack.setAttribute('data-hide-sm', '');\n    const vol = $('div', 'vs-video-player-vol');\n    const bVol = btn('Mute', I(ICON.vol) + I(ICON.mute, 'alt'));\n    const vs = $('div', 'vs-video-player-vslider', '<i></i><b></b>'); vs.tabIndex = 0; vs.setAttribute('role', 'slider'); vs.setAttribute('aria-label', 'Volume'); vs.setAttribute('aria-valuemin', '0'); vs.setAttribute('aria-valuemax', '100');\n    vol.append(bVol, vs); vol.setAttribute('data-hide-sm', '');\n    const time = $('div', 'vs-video-player-time', '0:00 <span>/ 0:00</span>');\n    const chap = $('div', 'vs-video-player-chap');\n    const bSpeed = btn('Playback speed', I(ICON.speed)); bSpeed.setAttribute('aria-haspopup', 'menu'); bSpeed.setAttribute('aria-expanded', 'false');\n    const bPip = btn('Picture in picture', I(ICON.pip)); bPip.setAttribute('data-hide-sm', '');\n    const bFs = btn('Full screen', I(ICON.fs) + I(ICON.fsx, 'alt'));\n    row.append(bPlay, bBack, vol, time, chap, bSpeed);\n    if (document.pictureInPictureEnabled && !video.disablePictureInPicture) row.append(bPip);\n    row.append(bFs);\n    ui.append(track, row);\n    const menu = $('div', 'vs-video-player-menu', '<p>Speed</p>'); menu.setAttribute('role', 'menu');\n    const rates = [0.5, 0.75, 1, 1.25, 1.5, 2].map(r => {\n      const b = $('button', '', r === 1 ? 'Normal' : r + '×'); b.type = 'button'; b.setAttribute('role', 'menuitemradio'); b.dataset.r = r;\n      b.addEventListener('click', () => { video.playbackRate = r; openMenu(false); flash(r + '×'); bSpeed.focus({ preventScroll: true }); }); menu.append(b); return b;\n    });\n    root.append(scrim, tapL, tapR, spin, bubble, big, ui, menu, live);\n\n    // segments\n    let dur = 0, segs = [];\n    function buildSegs() {\n      rail.textContent = ''; const cs = chapters.length ? chapters : [{ start: 0, title: '' }];\n      segs = cs.map((c, i) => {\n        const end = cs[i + 1] ? cs[i + 1].start : dur, el = $('div', 'vs-video-player-seg', '<i class=\"b\"></i><i class=\"h\"></i><i class=\"p\"></i>');\n        el.style.flex = `${Math.max(.001, end - c.start)} 1 0`; rail.append(el);\n        const [b, h, p] = el.children; return { ...c, end, el, b, h, p };\n      });\n    }\n    const xFor = t => { for (const s of segs) if (t <= s.end || s === segs[segs.length - 1]) return s.el.offsetLeft + clamp((t - s.start) / (s.end - s.start || 1), 0, 1) * s.el.offsetWidth; return 0; };\n    const tFor = x => {\n      for (let i = 0; i < segs.length; i++) { const s = segs[i], L = s.el.offsetLeft, R = L + s.el.offsetWidth, nextL = segs[i + 1] ? segs[i + 1].el.offsetLeft : Infinity;\n        if (x < nextL) return s.start + clamp((x - L) / (R - L || 1), 0, 1) * (s.end - s.start); }\n      return dur;\n    };\n    const fill = (key, t) => segs.forEach(s => s[key].style.transform = `scaleX(${clamp((t - s.start) / (s.end - s.start || 1), 0, 1)})`);\n    const chapAt = t => { let c = null; for (const s of chapters) if (t >= s.start - .01) c = s; return c; };\n\n    // preview frames from a hidden twin video\n    const pcv = preview.querySelector('canvas'), pctx = pcv.getContext('2d'); const frames = []; let framesOn = root.dataset.previews !== 'off', framing = false;\n    async function captureFrames() {\n      if (framing || !dur) return; framing = true;\n      const twin = document.createElement('video'); twin.muted = true; twin.preload = 'auto'; twin.playsInline = true; twin.crossOrigin = video.crossOrigin; twin.src = video.currentSrc || video.src;\n      await new Promise(r => twin.addEventListener('loadeddata', r, { once: true }));\n      const n = clamp(Math.round(dur / .75), 12, 80), c = document.createElement('canvas'); c.width = 352; c.height = 198; const cx = c.getContext('2d');\n      for (let i = 0; i < n; i++) {\n        const t = (i + .5) / n * dur; twin.currentTime = t;\n        await new Promise(r => twin.addEventListener('seeked', r, { once: true }));\n        try {\n          const vw = twin.videoWidth, vh = twin.videoHeight, k = Math.max(352 / vw, 198 / vh);\n          cx.drawImage(twin, (352 - vw * k) / 2, (198 - vh * k) / 2, vw * k, vh * k);\n          frames[i] = { t, img: await createImageBitmap(c) };\n        } catch (_) { preview.classList.add('no-frame'); break; }\n      }\n      twin.removeAttribute('src'); twin.load();\n    }\n    function drawFrame(t) {\n      if (!frames.length) { preview.classList.add('no-frame'); return; }\n      preview.classList.remove('no-frame'); let best = null;\n      for (const f of frames) if (f && (!best || Math.abs(f.t - t) < Math.abs(best.t - t))) best = f;\n      if (best) pctx.drawImage(best.img, 0, 0);\n    }\n\n    // state + rendering\n    let raf = 0, scrub = false, wasPlaying = false, hoverT = -1, idleT = 0, vis = true, vol0 = video.volume || 1;\n    function render() {\n      const t = scrub ? scrubT : video.currentTime;\n      fill('p', t);\n      let bEnd = 0; const br = video.buffered; for (let i = 0; i < br.length; i++) if (br.start(i) <= t + .5) bEnd = Math.max(bEnd, br.end(i)); fill('b', bEnd);\n      fill('h', hoverT >= 0 ? hoverT : 0);\n      segs.forEach(s => s.el.classList.toggle('is-hover', hoverT >= s.start && hoverT < s.end && segs.length > 1));\n      knob.style.transform = `translateX(${xFor(t)}px)`;\n      time.innerHTML = `${fmt(t)} <span>/ ${fmt(dur)}</span>`;\n      const c = chapAt(t); chap.textContent = c ? c.title : '';\n      track.setAttribute('aria-valuemax', String(Math.round(dur))); track.setAttribute('aria-valuenow', String(Math.round(t))); track.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(dur)}${c ? ', ' + c.title : ''}`);\n    }\n    const loop = () => { raf = 0; render(); if (!video.paused && vis) raf = requestAnimationFrame(loop); };\n    const kick = () => { if (!raf) raf = requestAnimationFrame(loop); };\n\n    function setVol(v, muted = v === 0) {\n      v = clamp(v, 0, 1); video.volume = v; video.muted = muted; if (v > 0) vol0 = v; paintVol();\n    }\n    function paintVol() {\n      const v = video.muted ? 0 : video.volume;\n      vs.querySelector('i').style.transform = `scaleX(${v})`; vs.querySelector('b').style.transform = `translateX(${v * 62}px)`;\n      bVol.classList.toggle('is-alt', v === 0); bVol.setAttribute('aria-label', v === 0 ? 'Unmute' : 'Mute');\n      bVol.querySelector('.w1').style.opacity = v > 0 ? 1 : 0; bVol.querySelector('.w2').style.opacity = v > .5 ? 1 : 0;\n      vs.setAttribute('aria-valuenow', String(Math.round(v * 100))); vs.setAttribute('aria-valuetext', Math.round(v * 100) + '%');\n    }\n\n    function toggle(on = video.paused) {\n      if (on) { const p = video.play(); p && p.catch(() => {}); } else video.pause();\n    }\n    function seekBy(d, show = true) { video.currentTime = clamp(video.currentTime + d, 0, dur); if (show) flash((d > 0 ? '+' : '−') + Math.abs(d) + 's'); kick(); }\n    function flash(content, isIcon) {\n      bubble.innerHTML = isIcon ? I(content) : content; if (RM.matches) return;\n      bubble.animate([{ opacity: 0, transform: 'scale(.7)' }, { opacity: 1, transform: 'scale(1)', offset: .25 }, { opacity: 0, transform: 'scale(1.35)' }], { duration: 650, easing: 'cubic-bezier(.2,0,0,1)' });\n    }\n    function tapFx(side) {\n      const el = side < 0 ? tapL : tapR; if (RM.matches) return;\n      el.animate([{ opacity: 0 }, { opacity: 1, offset: .2 }, { opacity: 0 }], { duration: 700, easing: 'ease-out' });\n    }\n    function wake() {\n      root.classList.remove('is-idle'); clearTimeout(idleT);\n      if (!video.paused && !scrub && !menu.classList.contains('is-open')) idleT = setTimeout(() => { if (!ui.matches(':hover') || matchMedia('(hover: none)').matches) root.classList.add('is-idle'); }, 2400);\n    }\n    function openMenu(on = !menu.classList.contains('is-open')) {\n      menu.classList.toggle('is-open', on); bSpeed.setAttribute('aria-expanded', String(on));\n      rates.forEach(b => b.setAttribute('aria-checked', String(+b.dataset.r === video.playbackRate)));\n      if (on) { const cur = rates.find(b => +b.dataset.r === video.playbackRate); cur && cur.focus({ preventScroll: true }); } wake();\n    }\n    function fullscreen() {\n      const d = document, el = d.fullscreenElement || d.webkitFullscreenElement;\n      if (el) (d.exitFullscreen || d.webkitExitFullscreen).call(d);\n      else if (root.requestFullscreen) root.requestFullscreen().catch(() => {});\n      else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen();\n      else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen();\n    }\n\n    // controls\n    big.addEventListener('click', () => toggle(true));\n    bPlay.addEventListener('click', () => toggle());\n    bBack.addEventListener('click', () => seekBy(-10));\n    bVol.addEventListener('click', () => { if (video.muted || video.volume === 0) setVol(vol0 || 1, false); else setVol(video.volume, true); });\n    bSpeed.addEventListener('click', e => { e.stopPropagation(); openMenu(); });\n    bPip.addEventListener('click', () => { (document.pictureInPictureElement ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}); });\n    bFs.addEventListener('click', fullscreen);\n    document.addEventListener('click', e => { if (!menu.contains(e.target) && e.target !== bSpeed) openMenu(false); });\n    const onFs = () => { const on = (document.fullscreenElement || document.webkitFullscreenElement) === root; bFs.classList.toggle('is-alt', on); bFs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen'); };\n    document.addEventListener('fullscreenchange', onFs); document.addEventListener('webkitfullscreenchange', onFs);\n\n    // surface: click toggles, double click fullscreens; touch taps show chrome, double taps seek\n    let lastTap = 0, tapTimer = 0;\n    root.addEventListener('pointerup', e => {\n      if (e.target !== video && e.target !== scrim && !e.target.classList.contains('vs-video-player-tap')) return;\n      if (e.pointerType === 'mouse') { if (e.button === 0) { toggle(); flash(video.paused ? ICON.pause : ICON.play, true); } return; }\n      const now = performance.now(), r = root.getBoundingClientRect(), side = e.clientX - r.left < r.width / 2 ? -1 : 1;\n      if (now - lastTap < 300) { clearTimeout(tapTimer); seekBy(side * 10, false); tapFx(side); lastTap = 0; return; }\n      lastTap = now;\n      tapTimer = setTimeout(() => { if (root.classList.contains('is-idle') || video.paused) wake(); else { clearTimeout(idleT); root.classList.add('is-idle'); } }, 280);\n    });\n    video.addEventListener('dblclick', fullscreen);\n    root.addEventListener('pointermove', e => { if (e.pointerType === 'mouse') wake(); });\n    root.addEventListener('pointerleave', () => { if (!video.paused && !scrub) { clearTimeout(idleT); idleT = setTimeout(() => root.classList.add('is-idle'), 700); } });\n    root.addEventListener('focusin', wake);\n\n    // scrubber\n    let scrubT = 0;\n    const railX = e => clamp(e.clientX - rail.getBoundingClientRect().left, 0, rail.offsetWidth);\n    function showPreview(x) {\n      const t = tFor(x), c = chapAt(t);\n      if (framesOn) drawFrame(t); preview.dataset.frames = framesOn ? 'on' : 'off';\n      preview.querySelector('b').textContent = c ? c.title : ''; preview.querySelector('span').textContent = fmt(t);\n      const pw = preview.offsetWidth || 176; preview.style.transform = `translateX(${clamp(x - pw / 2, 0, rail.offsetWidth - pw)}px)`;\n      if (preview.style.opacity !== '1') { preview.style.opacity = '1'; if (!RM.matches) preview.animate([{ opacity: 0, scale: '.9', translate: '0 6px' }, { opacity: 1, scale: '1', translate: '0 0' }], { duration: 320, easing: 'cubic-bezier(.3,1.4,.5,1)' }); }\n    }\n    const hidePreview = () => { preview.style.opacity = '0'; };\n    track.addEventListener('pointerenter', () => { knob.style.scale = '1'; });\n    track.addEventListener('pointermove', e => { if (!dur) return; const x = railX(e); hoverT = tFor(x); showPreview(x); if (scrub) { scrubT = hoverT; if (video.fastSeek && !RM.matches) video.fastSeek(scrubT); else video.currentTime = scrubT; } render(); });\n    track.addEventListener('pointerleave', () => { if (scrub) return; hoverT = -1; hidePreview(); knob.style.scale = ''; render(); });\n    track.addEventListener('pointerdown', e => {\n      if (!dur || e.button > 0) return; e.preventDefault(); track.setPointerCapture(e.pointerId); scrub = true; wasPlaying = !video.paused; video.pause();\n      root.classList.add('is-scrubbing'); knob.style.scale = '1'; const x = railX(e); scrubT = hoverT = tFor(x); video.currentTime = scrubT; showPreview(x); render(); wake();\n    });\n    const endScrub = e => {\n      if (!scrub) return; scrub = false; root.classList.remove('is-scrubbing'); video.currentTime = scrubT;\n      if (wasPlaying) toggle(true);\n      if (e.pointerType !== 'mouse') { hoverT = -1; hidePreview(); knob.style.scale = ''; }\n      render(); wake();\n    };\n    track.addEventListener('pointerup', endScrub); track.addEventListener('pointercancel', endScrub);\n    track.addEventListener('focus', () => { knob.style.scale = '1'; }); track.addEventListener('blur', () => { if (hoverT < 0) knob.style.scale = ''; });\n\n    // volume slider\n    let vdrag = false;\n    const volFrom = e => { const r = vs.getBoundingClientRect(); setVol(clamp((e.clientX - r.left - 6) / 62, 0, 1)); };\n    vs.addEventListener('pointerdown', e => { vdrag = true; vs.setPointerCapture(e.pointerId); root.classList.add('is-vdrag'); volFrom(e); });\n    vs.addEventListener('pointermove', e => vdrag && volFrom(e));\n    const vEnd = () => { vdrag = false; root.classList.remove('is-vdrag'); };\n    vs.addEventListener('pointerup', vEnd); vs.addEventListener('pointercancel', vEnd);\n\n    // keyboard\n    root.addEventListener('keydown', e => {\n      if (e.target.closest('.vs-video-player-menu')) {\n        const i = rates.indexOf(document.activeElement);\n        if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); rates[(i + (e.key === 'ArrowDown' ? 1 : -1) + rates.length) % rates.length].focus(); }\n        if (e.key === 'Escape') { openMenu(false); bSpeed.focus(); }\n        return;\n      }\n      if ((e.key === ' ' || e.key === 'Enter') && e.target.tagName === 'BUTTON') return;\n      const onVol = e.target === vs, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;\n      const act = {\n        ' ': () => { toggle(); flash(video.paused ? ICON.pause : ICON.play, true); }, k: () => { toggle(); flash(video.paused ? ICON.pause : ICON.play, true); },\n        j: () => seekBy(-10), l: () => seekBy(10),\n        ArrowLeft: () => onVol ? setVol(video.volume - .1) : seekBy(-5), ArrowRight: () => onVol ? setVol(video.volume + .1) : seekBy(5),\n        ArrowUp: () => { setVol(video.volume + .1, false); flash(Math.round(video.volume * 100) + '%'); }, ArrowDown: () => { setVol(video.volume - .1); flash(Math.round(video.volume * 100) + '%'); },\n        m: () => bVol.click(), f: fullscreen, Home: () => { video.currentTime = 0; kick(); }, End: () => { video.currentTime = dur - .1; kick(); },\n        Escape: () => openMenu(false),\n      }[k] || (/^[0-9]$/.test(k) ? () => { video.currentTime = dur * (+k / 10); kick(); } : null);\n      if (act) { e.preventDefault(); act(); wake(); }\n    });\n\n    // media events\n    const ready = () => { if (!video.duration || !isFinite(video.duration) || Math.abs(video.duration - dur) < .01) return; dur = video.duration; buildSegs(); render();\n      if (framesOn) setTimeout(() => (window.requestIdleCallback || setTimeout)(captureFrames), 600); };\n    video.addEventListener('loadedmetadata', ready); if (video.readyState >= 1) ready();\n    video.addEventListener('play', () => { root.classList.add('is-playing', 'is-started'); bPlay.classList.add('is-alt'); bPlay.setAttribute('aria-label', 'Pause'); live.textContent = 'Playing'; kick(); wake(); });\n    video.addEventListener('pause', () => { root.classList.remove('is-playing'); bPlay.classList.remove('is-alt'); bPlay.setAttribute('aria-label', 'Play'); if (!scrub) live.textContent = 'Paused'; wake(); render(); });\n    video.addEventListener('waiting', () => root.classList.add('is-waiting'));\n    ['playing', 'canplay', 'pause'].forEach(ev => video.addEventListener(ev, () => root.classList.remove('is-waiting')));\n    video.addEventListener('progress', () => { if (video.paused) render(); });\n    video.addEventListener('seeked', () => { if (video.paused) render(); });\n    video.addEventListener('volumechange', paintVol);\n    video.addEventListener('ended', () => { root.classList.remove('is-idle'); render(); });\n    new ResizeObserver(() => render()).observe(root);\n    new IntersectionObserver(([en]) => { vis = en.isIntersecting; if (vis) kick(); }).observe(root);\n    paintVol(); render();\n\n    const api = { play: () => toggle(true), pause: () => toggle(false), seek: t => { video.currentTime = t; kick(); }, video,\n      previews: on => { framesOn = on; if (on) captureFrames(); }, wake, flash };\n    root.__vsVideoPlayer = api; return api;\n  }\n  document.querySelectorAll('.vs-video-player').forEach(mount);\n  window.vsVideoPlayer = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "video",
      "player",
      "controls",
      "scrubber",
      "preview",
      "chapters",
      "fullscreen",
      "media"
    ],
    "tech": [
      "CSS",
      "JS",
      "Canvas"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#ff6a3d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/video-player/index.html",
    "thumbnail": "https://vividsites.app/api/component/video-player/thumb.jpg",
    "video": "https://vividsites.app/api/component/video-player/thumb.mp4",
    "page": "https://vividsites.app/c/video-player"
  }
}