Open demo

CSS code

HTML
<div><span class="lbl">Streaming player with an up next rail</span>
<div class="vd" data-vp="dk" data-title="Fractal Tide" data-autohide role="group" aria-label="Northlight Studies player" tabindex="-1">
  <div class="vd-stage" data-stage>
    <video src="../media/clip-1.mp4" poster="../media/clip-1-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-1-captions.vtt" srclang="en" label="English" default>
    </video>
    <p class="vd-cc-box" data-cc-box hidden></p>
    <div class="vd-top vd-ui"><p class="vd-show">Northlight Studies · Season 1</p><h3 data-now-title>Fractal Tide</h3></div>
    <div class="vd-center vd-ui">
      <button type="button" class="vd-btn" data-skip="-10" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="vd-btn vd-big" data-play aria-label="Play Fractal Tide"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
      <button type="button" class="vd-btn" data-skip="10" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
    </div>
    <div class="vd-bar vd-ui">
      <input type="range" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
      <div class="vd-row">
        <button type="button" class="vd-btn" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
        <input type="range" class="vd-vol" data-vol min="0" max="1" step="0.05" value="1" aria-label="Volume" aria-valuetext="100%">
        <span class="vd-time"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
        <span class="vd-spacer"></span>
        <button type="button" class="vd-btn vd-speed" data-speed><span class="vd-sr">Playback speed </span><span data-speed-val>1×</span></button>
        <button type="button" class="vd-btn vd-cc" data-cc aria-label="Captions" aria-pressed="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
        <button type="button" class="vd-btn" data-next aria-label="Next episode"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="16.4" y="5" width="2.6" height="14" rx="1"/><path d="M5 6.2v11.6a1 1 0 0 0 1.53.85l8.07-5.55a1.3 1.3 0 0 0 0-2.2L6.53 5.35A1 1 0 0 0 5 6.2z"/></svg></button>
        <button type="button" class="vd-btn" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
      </div>
    </div>
  </div>
  <div class="vd-rail-head"><h3>Up next</h3><p data-now-count data-label="Episode {n} of {total}">Episode 1 of 4</p></div>
  <ol class="vd-rail" aria-label="Episodes">
    <li><button type="button" class="vd-tile" style="--lp:0" data-item data-watched="0" data-src="../media/clip-1.mp4" data-poster="../media/clip-1-poster.jpg" data-captions="../media/clip-1-captions.vtt" data-title="Fractal Tide" data-duration="16" aria-current="true">
      <img src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog" aria-hidden="true"></span>
      <span class="vd-meta"><small>Episode 1 · 0:16</small><b>Fractal Tide</b></span><span class="vd-sr">, </span><span class="vd-sr" data-state>Not started</span></button></li>
    <li><button type="button" class="vd-tile" style="--lp:0.45" data-item data-watched="0.45" data-src="../media/clip-2.mp4" data-poster="../media/clip-2-poster.jpg" data-captions="../media/clip-2-captions.vtt" data-title="Rule 30 Loom" data-duration="20">
      <img src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog" aria-hidden="true"></span>
      <span class="vd-meta"><small>Episode 2 · 0:20</small><b>Rule 30 Loom</b></span><span class="vd-sr">, </span><span class="vd-sr" data-state>In progress</span></button></li>
    <li><button type="button" class="vd-tile" style="--lp:1" data-item data-watched="1" data-done data-src="../media/clip-3.mp4" data-poster="../media/clip-3-poster.jpg" data-captions="../media/clip-3-captions.vtt" data-title="Life in Colour" data-duration="14">
      <img src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog" aria-hidden="true"></span>
      <span class="vd-meta"><small>Episode 3 · 0:14</small><b>Life in Colour</b></span><span class="vd-sr">, </span><span class="vd-sr" data-state>Completed</span></button></li>
    <li><button type="button" class="vd-tile" style="--lp:0" data-item data-watched="0" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-duration="12">
      <img src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog" aria-hidden="true"></span>
      <span class="vd-meta"><small>Episode 4 · 0:12</small><b>Slow Gradients</b></span><span class="vd-sr">, </span><span class="vd-sr" data-state>Not started</span></button></li>
  </ol>
</div></div>

<div><span class="lbl">Compact dark card</span>
<div class="vd vd-card" data-vp="dk" data-title="Life in Colour" data-captions role="group" aria-label="Life in Colour player" tabindex="-1">
  <div class="vd-stage" data-stage>
    <video src="../media/clip-3.mp4" poster="../media/clip-3-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-3-captions.vtt" srclang="en" label="English" default>
    </video>
    <p class="vd-cc-box" data-cc-box hidden></p>
    <div class="vd-center vd-ui"><button type="button" class="vd-btn vd-big" data-play aria-label="Play Life in Colour"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button></div>
  </div>
  <div class="vd-controls vd-ui">
    <span class="vd-time" data-cur>0:00</span>
    <input type="range" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
    <span class="vd-time" data-dur>0:14</span>
    <button type="button" class="vd-btn" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
    <button type="button" class="vd-btn" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
  </div>
  <div class="vd-info">
    <h3>Life in Colour</h3>
    <p>Conway's Game of Life on a 240 by 135 grid, with dying cells fading from pink to deep indigo. Captions describe the score.</p>
    <ul class="vd-tags" aria-label="Details"><li>Study 03</li><li>0:14</li><li>Captions</li><li>Original score</li></ul>
  </div>
</div></div>
CSS
/* Dark streaming-style players over a native <video>. The script sets --p, --b and --v on .vd, toggles
   [data-playing], [data-idle] and [data-fullscreen], and marks the current "up next" tile with aria-current. */
.vd{--vd-accent:#e11d48;--vd-ink:#f1f5f9;--vd-muted:#a1a9b8;--vd-surface:#151922;--vd-line:#262c38;--p:0;--b:0;--v:1;color:var(--vd-ink)}
.vd:focus{outline:none}
.vd [hidden]{display:none !important}
.vd button{font:inherit;color:inherit;cursor:pointer}
.vd-stage{position:relative;overflow:hidden;border-radius:14px;background:#000;isolation:isolate;box-shadow:0 24px 60px rgba(0,0,0,.55)}
.vd video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;background:#000}
.vd-stage:fullscreen{border-radius:0}
.vd-stage:fullscreen video{height:100%;object-fit:contain}
.vd:not([data-ready]) .vd-ui{display:none}
.vd[data-idle] .vd-stage{cursor:none}
.vd-ui{transition:opacity .3s}
.vd[data-idle] .vd-ui{opacity:0;pointer-events:none}
.vd-btn{position:relative;flex:none;display:grid;place-items:center;width:40px;height:40px;padding:0;border:0;border-radius:50%;background:none}
.vd-btn:hover{background:rgba(255,255,255,.14)}
.vd-btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vd-btn svg{display:block;width:22px;height:22px}
.vd .i-pause,.vd[data-playing] .i-play,.vd .i-shrink,.vd[data-fullscreen] .i-expand,.vd .i-muted,.vd [aria-pressed="true"] > .i-vol{display:none}
.vd[data-playing] .i-pause,.vd[data-fullscreen] .i-shrink,.vd [aria-pressed="true"] > .i-muted{display:block}
.vd-cc[aria-pressed="true"]{color:#fff;background:rgba(225,29,72,.3)}
.vd-speed{width:auto;min-width:48px;padding:0 10px;border-radius:99px;font-size:.84rem;font-weight:700;font-variant-numeric:tabular-nums}
.vd-time{padding:0 8px;font-size:.84rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.vd-spacer{flex:1}
.vd-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* top title, centre cluster, bottom bar */
.vd-top{position:absolute;inset:0 0 auto;z-index:2;padding:18px 22px 50px;background:linear-gradient(rgba(0,0,0,.75),transparent);pointer-events:none}
.vd-show{font-size:.78rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#fda4af}
.vd-top h3{margin-top:4px;font-size:1.35rem;letter-spacing:-.01em}
.vd-center{position:absolute;left:50%;top:50%;z-index:2;display:flex;align-items:center;gap:22px;transform:translate(-50%,-50%)}
.vd-center .vd-btn{width:52px;height:52px;background:rgba(0,0,0,.45)}
.vd-center .vd-btn svg{width:28px;height:28px}
.vd-center .vd-big{width:76px;height:76px;background:var(--vd-accent);box-shadow:0 10px 30px rgba(225,29,72,.45)}
.vd-center .vd-big:hover{background:#be123c}
.vd-center .vd-big svg{width:34px;height:34px}
.vd-bar{position:absolute;inset:auto 0 0;z-index:2;padding:40px 14px 10px;background:linear-gradient(to top,rgba(0,0,0,.88),rgba(0,0,0,.5) 60%,transparent)}
.vd-row{display:flex;align-items:center;gap:2px;margin-top:6px}
.vd input[type="range"]{-webkit-appearance:none;appearance:none;display:block;width:100%;min-width:0;height:16px;margin:0;background:none;cursor:pointer;--f:var(--p);--fb:var(--b);
  --fill:linear-gradient(to right,var(--vd-accent) 0 calc(7px + (100% - 14px) * var(--f)),rgba(255,255,255,.6) 0 calc(7px + (100% - 14px) * var(--fb)),rgba(255,255,255,.35) 0)}
.vd input[type="range"].vd-vol{--f:var(--v);--fb:0;flex:none;width:80px}
.vd input[type="range"]:focus-visible{outline:none}
.vd input[type="range"]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vd input[type="range"]::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vd input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:0;border-radius:50%;background:var(--vd-accent)}
.vd input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:0;border-radius:50%;background:var(--vd-accent)}
.vd input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #000,0 0 0 5px #fff}
.vd input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #000,0 0 0 5px #fff}
.vd-cc-box{position:absolute;left:50%;bottom:100px;z-index:1;transform:translateX(-50%);width:max-content;max-width:86%;margin:0;padding:.3em .8em;border-radius:6px;
  background:rgba(0,0,0,.8);color:#fff;font-size:clamp(.85rem,2.4vw,1.15rem);line-height:1.4;text-align:center;white-space:pre-line;pointer-events:none}
.vd[data-idle] .vd-cc-box{bottom:20px}
/* up next rail */
.vd-rail-head{display:flex;align-items:baseline;justify-content:space-between;margin:22px 2px 12px}
.vd-rail-head h3{font-size:1.05rem}
.vd-rail-head p{font-size:.82rem;color:var(--vd-muted)}
.vd-rail{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;list-style:none}
.vd-tile{--lp:0;position:relative;display:block;width:100%;padding:0;border:0;border-radius:12px;background:var(--vd-surface);text-align:left;overflow:hidden}
.vd-tile img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;opacity:.85;transition:opacity .2s}
.vd-tile:hover img{opacity:1}
.vd-tile .vd-prog{display:block;height:3px;background:linear-gradient(to right,var(--vd-accent) calc(var(--lp) * 100%),#3a4150 0)}
.vd-tile .vd-meta{display:block;padding:9px 12px 12px}
.vd-tile small{display:block;font-size:.74rem;color:var(--vd-muted)}
.vd-tile b{display:block;margin-top:2px;font-size:.9rem}
.vd-tile:focus-visible{outline:2px solid #fff;outline-offset:2px}
.vd-tile[aria-current="true"]{box-shadow:0 0 0 2px var(--vd-accent)}
.vd-tile[aria-current="true"] small{color:#fda4af}
/* 2. compact dark card */
.vd-card{max-width:440px;border:1px solid var(--vd-line);border-radius:20px;background:var(--vd-surface);overflow:hidden}
.vd-card .vd-stage{border-radius:0;box-shadow:none}
.vd-card .vd-center .vd-big{width:64px;height:64px}
.vd-card .vd-center .vd-big svg{width:28px;height:28px}
.vd-card .vd-cc-box{bottom:14px}
.vd-card .vd-controls{display:flex;align-items:center;gap:2px;padding:8px 10px 4px}
.vd-card .vd-controls input{flex:1;margin:0 4px}
.vd-card .vd-btn:hover{background:#232936}
.vd-info{padding:8px 20px 20px}
.vd-info h3{font-size:1.15rem}
.vd-info p{margin-top:6px;font-size:.88rem;line-height:1.5;color:var(--vd-muted)}
.vd-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px;list-style:none}
.vd-tags li{padding:3px 10px;border:1px solid #3a4150;border-radius:99px;font-size:.75rem;color:#cbd5e1}
@media (max-width:760px){.vd-rail{grid-template-columns:repeat(2,minmax(0,1fr))}.vd-skip-sm,.vd input[type="range"].vd-vol{display:none}}
@media (max-width:560px){.vd-top{padding:12px 14px 40px}.vd-top h3{font-size:1.05rem}.vd-center [data-skip],.vd-show{display:none}
  .vd-center .vd-big{width:54px;height:54px}.vd-center .vd-big svg{width:26px;height:26px}.vd-cc-box{bottom:92px}.vd-speed{display:none}}
@media (prefers-reduced-motion:reduce){.vd-ui,.vd-tile img{transition:none}}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

  const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
  const sync = () => {
    const t = video.currentTime, d = length();
    root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
    if (cur) cur.textContent = vpFmt(t);
    if (seek) {
      seek.value = t;
      const text = vpFmt(t) + ' of ' + vpFmt(d);
      if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
    }
    listeners.forEach(fn => fn(t, d));
  };
  const buffered = () => {
    const b = video.buffered, d = length();
    let end = 0;
    for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
    root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
  };
  const tick = () => { sync(); raf = requestAnimationFrame(tick); };
  const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
  const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
  const start = () => {
    // one player at a time; muted background loops (data-bg) keep running
    document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
    return video.play().catch(() => {});
  };
  const toggle = () => (video.paused || video.ended ? start() : video.pause());
  const name = () => root.dataset.title || 'video';
  const state = () => {
    const playing = !video.paused;
    root.toggleAttribute('data-playing', playing);
    plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
    // if the focused play button was just hidden (a big centre button), hand focus to a visible one
    const a = document.activeElement;
    if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
  };

  // Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
  // so the text sits above the control bar. Players without a box fall back to native rendering.
  // A player without a captions button shows captions when the root has data-captions.
  const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
  const render = () => {
    if (!ccBox) return;
    const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
    const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
    ccBox.textContent = text;
    ccBox.hidden = !text;
  };
  const captions = () => {
    track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
    if (cc) cc.hidden = !track;
    if (!track) return render();
    track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
    track.oncuechange = render;
    render();
  };

  // Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
  const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
  function wake() {
    root.removeAttribute('data-idle');
    clearTimeout(idle);
    if (!root.hasAttribute('data-autohide') || video.paused) return;
    idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
  }

  const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
  const fullscreen = () => {
    if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
    else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
    else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
    else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
  };
  const fsSync = () => {
    const on = fsElement() === stage;
    root.toggleAttribute('data-fullscreen', on);
    if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
  };

  plays.forEach(b => b.addEventListener('click', toggle));
  root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
  root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
  video.addEventListener('click', () => {
    // a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
    if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
    toggle();
    if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
  });
  video.addEventListener('play', () => { state(); tick(); wake(); });
  video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
  video.addEventListener('loadedmetadata', () => { meta(); captions(); });
  video.addEventListener('progress', buffered);
  video.addEventListener('seeked', () => { sync(); buffered(); });
  video.addEventListener('volumechange', () => {
    const v = video.muted ? 0 : video.volume;
    root.style.setProperty('--v', v);
    if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
    if (mute) mute.setAttribute('aria-pressed', String(v === 0));
  });
  video.addEventListener('ratechange', () => {
    if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
  });
  video.textTracks.addEventListener('addtrack', captions);
  video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
  if (seek) seek.addEventListener('input', () => jump(+seek.value));
  if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
  if (mute) mute.addEventListener('click', () => {
    if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
    else video.muted = true;
  });
  if (cc) cc.addEventListener('click', () => {
    cc.setAttribute('aria-pressed', String(!ccOn()));
    if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
    render();
  });
  if (speed) speed.addEventListener('click', () => {
    const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
    video.playbackRate = video.defaultPlaybackRate = rate;
  });
  if (fs) {
    fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
    fs.addEventListener('click', fullscreen);
    document.addEventListener('fullscreenchange', fsSync);
    document.addEventListener('webkitfullscreenchange', fsSync);
  }
  if (pip) {
    pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
    pip.addEventListener('click', () => (document.pictureInPictureElement === video
      ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
    video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
    video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
  }
  ['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
  root.addEventListener('pointerleave', () => {
    if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
    clearTimeout(idle);
    idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
  });
  root.addEventListener('keydown', e => {
    if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
    const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
    wake();
    if (k === 'k' || (k === ' ' && !control)) toggle();
    else if (k === 'm') video.muted = !video.muted;
    else if (k === 'f' && fs && !fs.hidden) fullscreen();
    else if (k === 'c' && cc && !cc.hidden) cc.click();
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
      jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
    else return;
    e.preventDefault();
  });

  // load() swaps the source, poster and captions file (playlists and lightboxes use it)
  const load = ({ src, poster, captions: vtt, title, duration }) => {
    video.pause();
    video.querySelectorAll('track').forEach(t => t.remove());
    if (title) root.dataset.title = title;
    video.poster = poster || '';
    video.src = src;
    if (vtt) {
      const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
      t.addEventListener('load', captions);
      video.append(t);
    }
    if (seek && duration) seek.max = duration;
    if (dur && duration) dur.textContent = vpFmt(duration);
    video.load();
    state(); sync(); captions();
  };

  if (video.readyState > 0) meta();
  captions(); state(); fsSync();
  video.dispatchEvent(new Event('volumechange'));
  return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}

// Dark streaming players. A player may carry an "up next" rail: each [data-item] button holds data-src,
// data-poster, data-captions, data-title and data-duration, plus data-watched (0 to 1) for the
// continue-watching bar it paints through --lp. The current tile has aria-current.
document.querySelectorAll('[data-vp="dk"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const items = [...root.querySelectorAll('[data-item]')];
  if (!items.length) return;
  const prev = root.querySelector('[data-prev]'), next = root.querySelector('[data-next]');
  const title = root.querySelector('[data-now-title]'), count = root.querySelector('[data-now-count]');
  let index = Math.max(0, items.findIndex(i => i.getAttribute('aria-current') === 'true'));

  const mark = () => {
    items.forEach((it, n) => { if (n === index) it.setAttribute('aria-current', 'true'); else it.removeAttribute('aria-current'); });
    // aria-disabled rather than disabled, so a focused button keeps focus at either end of the list
    if (prev) prev.setAttribute('aria-disabled', String(index === 0));
    if (next) next.setAttribute('aria-disabled', String(index === items.length - 1));
    if (title) title.textContent = items[index].dataset.title;
    if (count) count.textContent = count.dataset.label.replace('{n}', index + 1).replace('{total}', items.length);
  };
  const progress = (it, share) => {
    const watched = Math.max(+it.dataset.watched || 0, share);
    it.dataset.watched = watched;
    it.style.setProperty('--lp', watched);
    const state = watched > 0.97 ? 'Completed' : watched > 0 ? 'In progress' : 'Not started';
    const label = it.querySelector('[data-state]');
    if (label && label.textContent !== state) label.textContent = state;
    it.toggleAttribute('data-done', state === 'Completed');
  };
  const select = (n, play) => {
    if (n < 0 || n >= items.length) return;
    index = n;
    const it = items[n];
    player.load({ src: it.dataset.src, poster: it.dataset.poster, captions: it.dataset.captions, title: it.dataset.title, duration: +it.dataset.duration });
    // continue watching: resume where the visitor left off unless the video was finished
    const resume = +it.dataset.watched || 0;
    if (resume > 0 && resume < 0.97) video.addEventListener('loadedmetadata', () => player.jump(resume * video.duration), { once: true });
    mark();
    if (play) player.start();
  };

  player.onTime((t, d) => { if (d) progress(items[index], t / d); });
  video.addEventListener('ended', () => { progress(items[index], 1); select(index + 1, true); });
  items.forEach((it, n) => it.addEventListener('click', () => select(n, true)));
  if (prev) prev.addEventListener('click', () => select(index - 1, !video.paused));
  if (next) next.addEventListener('click', () => select(index + 1, !video.paused));
  mark();
});
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Streaming player with an up next rail</span>
<div class="vd" data-vp="dk" data-title="Fractal Tide" data-autohide role="group" aria-label="Northlight Studies player" tabindex="-1">
  <div class="vd-stage position-relative overflow-hidden rounded-3" data-stage>
    <div class="ratio ratio-16x9">
      <video src="../media/clip-1.mp4" poster="../media/clip-1-poster.jpg" preload="metadata" playsinline controls>
        <track kind="captions" src="../media/clip-1-captions.vtt" srclang="en" label="English" default>
      </video>
    </div>
    <p class="vd-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
    <div class="vd-top vd-ui position-absolute top-0 start-0 end-0"><p class="vd-show fw-bold text-uppercase mb-1">Northlight Studies · Season 1</p><h3 class="fw-bold mb-0" data-now-title>Fractal Tide</h3></div>
    <div class="vd-center vd-ui position-absolute d-flex align-items-center">
      <button type="button" class="btn vd-btn" data-skip="-10" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="btn vd-btn vd-big" data-play aria-label="Play Fractal Tide"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
      <button type="button" class="btn vd-btn" data-skip="10" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
    </div>
    <div class="vd-bar vd-ui position-absolute bottom-0 start-0 end-0">
      <input type="range" class="form-range d-block" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
      <div class="d-flex align-items-center gap-1 mt-1">
        <button type="button" class="btn vd-btn" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
        <input type="range" class="form-range vd-vol flex-shrink-0" data-vol min="0" max="1" step="0.05" value="1" aria-label="Volume" aria-valuetext="100%">
        <span class="vd-time px-2 me-auto text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
        <button type="button" class="btn vd-btn vd-speed" data-speed><span class="visually-hidden">Playback speed </span><span data-speed-val>1×</span></button>
        <button type="button" class="btn vd-btn vd-cc" data-cc aria-label="Captions" aria-pressed="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
        <button type="button" class="btn vd-btn" data-next aria-label="Next episode"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="16.4" y="5" width="2.6" height="14" rx="1"/><path d="M5 6.2v11.6a1 1 0 0 0 1.53.85l8.07-5.55a1.3 1.3 0 0 0 0-2.2L6.53 5.35A1 1 0 0 0 5 6.2z"/></svg></button>
        <button type="button" class="btn vd-btn" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
      </div>
    </div>
  </div>
  <div class="vd-rail-head d-flex align-items-baseline justify-content-between mt-4 mb-3 mx-1"><h3 class="fw-bold mb-0">Up next</h3><p class="mb-0" data-now-count data-label="Episode {n} of {total}">Episode 1 of 4</p></div>
  <ol class="row row-cols-2 row-cols-md-4 g-3 list-unstyled mb-0" aria-label="Episodes">
    <li class="col"><button type="button" class="vd-tile btn position-relative d-block w-100 overflow-hidden text-start" style="--lp:0" data-item data-watched="0" data-src="../media/clip-1.mp4" data-poster="../media/clip-1-poster.jpg" data-captions="../media/clip-1-captions.vtt" data-title="Fractal Tide" data-duration="16" aria-current="true">
      <img class="d-block w-100 h-auto" src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog d-block" aria-hidden="true"></span>
      <span class="vd-meta d-block px-3 pt-2 pb-3"><small class="d-block">Episode 1 · 0:16</small><b class="d-block mt-1">Fractal Tide</b></span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span></button></li>
    <li class="col"><button type="button" class="vd-tile btn position-relative d-block w-100 overflow-hidden text-start" style="--lp:0.45" data-item data-watched="0.45" data-src="../media/clip-2.mp4" data-poster="../media/clip-2-poster.jpg" data-captions="../media/clip-2-captions.vtt" data-title="Rule 30 Loom" data-duration="20">
      <img class="d-block w-100 h-auto" src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog d-block" aria-hidden="true"></span>
      <span class="vd-meta d-block px-3 pt-2 pb-3"><small class="d-block">Episode 2 · 0:20</small><b class="d-block mt-1">Rule 30 Loom</b></span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>In progress</span></button></li>
    <li class="col"><button type="button" class="vd-tile btn position-relative d-block w-100 overflow-hidden text-start" style="--lp:1" data-item data-watched="1" data-done data-src="../media/clip-3.mp4" data-poster="../media/clip-3-poster.jpg" data-captions="../media/clip-3-captions.vtt" data-title="Life in Colour" data-duration="14">
      <img class="d-block w-100 h-auto" src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog d-block" aria-hidden="true"></span>
      <span class="vd-meta d-block px-3 pt-2 pb-3"><small class="d-block">Episode 3 · 0:14</small><b class="d-block mt-1">Life in Colour</b></span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Completed</span></button></li>
    <li class="col"><button type="button" class="vd-tile btn position-relative d-block w-100 overflow-hidden text-start" style="--lp:0" data-item data-watched="0" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-duration="12">
      <img class="d-block w-100 h-auto" src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vd-prog d-block" aria-hidden="true"></span>
      <span class="vd-meta d-block px-3 pt-2 pb-3"><small class="d-block">Episode 4 · 0:12</small><b class="d-block mt-1">Slow Gradients</b></span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span></button></li>
  </ol>
</div></div>

<div><span class="lbl">Compact dark card</span>
<div class="vd vd-card card overflow-hidden" data-vp="dk" data-title="Life in Colour" data-captions role="group" aria-label="Life in Colour player" tabindex="-1">
  <div class="vd-stage position-relative overflow-hidden" data-stage>
    <div class="ratio ratio-16x9">
      <video src="../media/clip-3.mp4" poster="../media/clip-3-poster.jpg" preload="metadata" playsinline controls>
        <track kind="captions" src="../media/clip-3-captions.vtt" srclang="en" label="English" default>
      </video>
    </div>
    <p class="vd-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
    <div class="vd-center vd-ui position-absolute d-flex align-items-center"><button type="button" class="btn vd-btn vd-big" data-play aria-label="Play Life in Colour"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button></div>
  </div>
  <div class="vd-controls vd-ui d-flex align-items-center gap-1 px-2 pt-2 pb-1">
    <span class="vd-time px-2" data-cur>0:00</span>
    <input type="range" class="form-range flex-grow-1 w-auto mx-1" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
    <span class="vd-time px-2" data-dur>0:14</span>
    <button type="button" class="btn vd-btn" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
    <button type="button" class="btn vd-btn" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
  </div>
  <div class="vd-info card-body pt-2 px-4 pb-4">
    <h3 class="fs-5 fw-bold mb-0">Life in Colour</h3>
    <p class="mt-2 mb-0">Conway's Game of Life on a 240 by 135 grid, with dying cells fading from pink to deep indigo. Captions describe the score.</p>
    <ul class="vd-tags d-flex flex-wrap gap-2 list-unstyled mt-3 mb-0" aria-label="Details"><li class="badge rounded-pill">Study 03</li><li class="badge rounded-pill">0:14</li><li class="badge rounded-pill">Captions</li><li class="badge rounded-pill">Original score</li></ul>
  </div>
</div></div>
CSS
/* Dark streaming-style players on Bootstrap 5 under data-bs-theme="dark": .btn controls, .form-range sliders, a .row
   of episode tiles and a .card. The script sets --p, --b and --v and marks the current tile with aria-current. */
.vd{--vd-accent:#e11d48;--vd-muted:#a1a9b8;--vd-surface:#151922;--vd-line:#262c38;--p:0;--b:0;--v:1;color:#f1f5f9}
.vd:focus{outline:none}
.vd-stage{background:#000;isolation:isolate;box-shadow:0 24px 60px rgba(0,0,0,.55)}
.vd video{object-fit:cover;background:#000}
.vd-stage:fullscreen{border-radius:0 !important}
.vd-stage:fullscreen .ratio{height:100%}
.vd-stage:fullscreen .ratio::before{display:none}
.vd-stage:fullscreen video{object-fit:contain}
.vd:not([data-ready]) .vd-ui{display:none !important}
.vd[data-idle] .vd-stage{cursor:none}
.vd-ui{transition:opacity .3s}
.vd[data-idle] .vd-ui{opacity:0;pointer-events:none}
.vd .vd-btn{--bs-btn-color:#f1f5f9;--bs-btn-hover-color:#fff;--bs-btn-active-color:#fff;--bs-btn-hover-bg:rgba(255,255,255,.14);--bs-btn-active-bg:rgba(255,255,255,.22);
  --bs-btn-border-width:0;--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-radius:50%;--bs-btn-focus-box-shadow:none;
  position:relative;display:grid;place-items:center;flex:none;width:40px;height:40px}
.vd .vd-btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vd .vd-btn svg{display:block;width:22px;height:22px}
.vd .btn .i-pause,.vd[data-playing] .btn .i-play,.vd .btn .i-shrink,.vd[data-fullscreen] .btn .i-expand,.vd .btn .i-muted,.vd .btn[aria-pressed="true"] .i-vol{display:none}
.vd[data-playing] .btn .i-pause,.vd[data-fullscreen] .btn .i-shrink,.vd .btn[aria-pressed="true"] .i-muted{display:block}
.vd .vd-cc[aria-pressed="true"]{--bs-btn-bg:rgba(225,29,72,.3)}
.vd .vd-speed{--bs-btn-border-radius:99px;--bs-btn-font-size:.84rem;--bs-btn-font-weight:700;width:auto;min-width:48px;padding:0 10px;font-variant-numeric:tabular-nums}
.vd-time{font-size:.84rem;font-variant-numeric:tabular-nums}
.vd-top{z-index:2;padding:18px 22px 50px;background:linear-gradient(rgba(0,0,0,.75),transparent);pointer-events:none}
.vd-show{font-size:.78rem;letter-spacing:.12em;color:#fda4af}
.vd-top h3{font-size:1.35rem;letter-spacing:-.01em}
.vd-center{left:50%;top:50%;z-index:2;gap:22px;transform:translate(-50%,-50%)}
.vd-center .vd-btn{width:52px;height:52px;--bs-btn-bg:rgba(0,0,0,.45)}
.vd-center .vd-btn svg{width:28px;height:28px}
.vd-center .vd-big{width:76px;height:76px;--bs-btn-bg:var(--vd-accent);--bs-btn-hover-bg:#be123c;--bs-btn-active-bg:#9f1239;box-shadow:0 10px 30px rgba(225,29,72,.45)}
.vd-center .vd-big svg{width:34px;height:34px}
.vd-bar{z-index:2;padding:40px 14px 10px;background:linear-gradient(to top,rgba(0,0,0,.88),rgba(0,0,0,.5) 60%,transparent)}
.vd .form-range{height:16px;--f:var(--p);--fb:var(--b);
  --fill:linear-gradient(to right,var(--vd-accent) 0 calc(7px + (100% - 14px) * var(--f)),rgba(255,255,255,.6) 0 calc(7px + (100% - 14px) * var(--fb)),rgba(255,255,255,.35) 0)}
.vd .form-range.vd-vol{--f:var(--v);--fb:0;width:80px}
.vd .form-range::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vd .form-range::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vd .form-range::-webkit-slider-thumb{width:14px;height:14px;margin-top:-5px;background:var(--vd-accent);transition:none}
.vd .form-range::-moz-range-thumb{width:14px;height:14px;border:0;background:var(--vd-accent);transition:none}
.vd .form-range:focus::-webkit-slider-thumb{box-shadow:none}
.vd .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #000,0 0 0 5px #fff}
.vd .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #000,0 0 0 5px #fff}
.vd-cc-box{position:absolute;left:50%;bottom:100px;z-index:1;transform:translateX(-50%);width:max-content;max-width:86%;padding:.3em .8em;
  background:rgba(0,0,0,.8);color:#fff;font-size:clamp(.85rem,2.4vw,1.15rem);line-height:1.4;white-space:pre-line;pointer-events:none}
.vd[data-idle] .vd-cc-box{bottom:20px}
.vd-rail-head h3{font-size:1.05rem}
.vd-rail-head p{font-size:.82rem;color:var(--vd-muted)}
.vd-tile{--lp:0;--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:12px;--bs-btn-bg:var(--vd-surface);--bs-btn-hover-bg:var(--vd-surface);
  --bs-btn-active-bg:var(--vd-surface);--bs-btn-color:#f1f5f9;--bs-btn-hover-color:#fff;--bs-btn-focus-box-shadow:none}
.vd-tile img{aspect-ratio:16/9;object-fit:cover;opacity:.85;transition:opacity .2s}
.vd-tile:hover img{opacity:1}
.vd-tile .vd-prog{height:3px;background:linear-gradient(to right,var(--vd-accent) calc(var(--lp) * 100%),#3a4150 0)}
.vd-tile small{font-size:.74rem;color:var(--vd-muted)}
.vd-tile b{font-size:.9rem}
.vd-tile:focus-visible{outline:2px solid #fff;outline-offset:2px}
.vd-tile[aria-current="true"]{box-shadow:0 0 0 2px var(--vd-accent)}
.vd-tile[aria-current="true"] small{color:#fda4af}
.vd-card{--bs-card-bg:var(--vd-surface);--bs-card-border-color:var(--vd-line);--bs-card-border-radius:20px;max-width:440px}
.vd-card .vd-stage{box-shadow:none}
.vd-card .vd-center .vd-big{width:64px;height:64px}
.vd-card .vd-center .vd-big svg{width:28px;height:28px}
.vd-card .vd-cc-box{bottom:14px}
.vd-card .vd-controls .vd-btn{--bs-btn-hover-bg:#232936}
.vd-info p{font-size:.88rem;line-height:1.5;color:var(--vd-muted)}
.vd-tags .badge{--bs-badge-font-size:.75rem;--bs-badge-font-weight:500;--bs-badge-padding-x:10px;--bs-badge-padding-y:4px;border:1px solid #3a4150;color:#cbd5e1}
@media (max-width:760px){.vd-skip-sm,.vd .form-range.vd-vol{display:none}}
@media (max-width:560px){.vd-top{padding:12px 14px 40px}.vd-top h3{font-size:1.05rem}.vd-center [data-skip],.vd-show{display:none !important}
  .vd-center .vd-big{width:54px;height:54px}.vd-center .vd-big svg{width:26px;height:26px}.vd-cc-box{bottom:92px}.vd .vd-speed{display:none}}
@media (prefers-reduced-motion:reduce){.vd-ui,.vd-tile img{transition:none}}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

  const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
  const sync = () => {
    const t = video.currentTime, d = length();
    root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
    if (cur) cur.textContent = vpFmt(t);
    if (seek) {
      seek.value = t;
      const text = vpFmt(t) + ' of ' + vpFmt(d);
      if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
    }
    listeners.forEach(fn => fn(t, d));
  };
  const buffered = () => {
    const b = video.buffered, d = length();
    let end = 0;
    for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
    root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
  };
  const tick = () => { sync(); raf = requestAnimationFrame(tick); };
  const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
  const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
  const start = () => {
    // one player at a time; muted background loops (data-bg) keep running
    document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
    return video.play().catch(() => {});
  };
  const toggle = () => (video.paused || video.ended ? start() : video.pause());
  const name = () => root.dataset.title || 'video';
  const state = () => {
    const playing = !video.paused;
    root.toggleAttribute('data-playing', playing);
    plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
    // if the focused play button was just hidden (a big centre button), hand focus to a visible one
    const a = document.activeElement;
    if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
  };

  // Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
  // so the text sits above the control bar. Players without a box fall back to native rendering.
  // A player without a captions button shows captions when the root has data-captions.
  const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
  const render = () => {
    if (!ccBox) return;
    const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
    const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
    ccBox.textContent = text;
    ccBox.hidden = !text;
  };
  const captions = () => {
    track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
    if (cc) cc.hidden = !track;
    if (!track) return render();
    track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
    track.oncuechange = render;
    render();
  };

  // Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
  const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
  function wake() {
    root.removeAttribute('data-idle');
    clearTimeout(idle);
    if (!root.hasAttribute('data-autohide') || video.paused) return;
    idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
  }

  const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
  const fullscreen = () => {
    if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
    else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
    else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
    else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
  };
  const fsSync = () => {
    const on = fsElement() === stage;
    root.toggleAttribute('data-fullscreen', on);
    if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
  };

  plays.forEach(b => b.addEventListener('click', toggle));
  root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
  root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
  video.addEventListener('click', () => {
    // a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
    if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
    toggle();
    if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
  });
  video.addEventListener('play', () => { state(); tick(); wake(); });
  video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
  video.addEventListener('loadedmetadata', () => { meta(); captions(); });
  video.addEventListener('progress', buffered);
  video.addEventListener('seeked', () => { sync(); buffered(); });
  video.addEventListener('volumechange', () => {
    const v = video.muted ? 0 : video.volume;
    root.style.setProperty('--v', v);
    if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
    if (mute) mute.setAttribute('aria-pressed', String(v === 0));
  });
  video.addEventListener('ratechange', () => {
    if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
  });
  video.textTracks.addEventListener('addtrack', captions);
  video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
  if (seek) seek.addEventListener('input', () => jump(+seek.value));
  if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
  if (mute) mute.addEventListener('click', () => {
    if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
    else video.muted = true;
  });
  if (cc) cc.addEventListener('click', () => {
    cc.setAttribute('aria-pressed', String(!ccOn()));
    if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
    render();
  });
  if (speed) speed.addEventListener('click', () => {
    const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
    video.playbackRate = video.defaultPlaybackRate = rate;
  });
  if (fs) {
    fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
    fs.addEventListener('click', fullscreen);
    document.addEventListener('fullscreenchange', fsSync);
    document.addEventListener('webkitfullscreenchange', fsSync);
  }
  if (pip) {
    pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
    pip.addEventListener('click', () => (document.pictureInPictureElement === video
      ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
    video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
    video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
  }
  ['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
  root.addEventListener('pointerleave', () => {
    if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
    clearTimeout(idle);
    idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
  });
  root.addEventListener('keydown', e => {
    if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
    const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
    wake();
    if (k === 'k' || (k === ' ' && !control)) toggle();
    else if (k === 'm') video.muted = !video.muted;
    else if (k === 'f' && fs && !fs.hidden) fullscreen();
    else if (k === 'c' && cc && !cc.hidden) cc.click();
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
      jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
    else return;
    e.preventDefault();
  });

  // load() swaps the source, poster and captions file (playlists and lightboxes use it)
  const load = ({ src, poster, captions: vtt, title, duration }) => {
    video.pause();
    video.querySelectorAll('track').forEach(t => t.remove());
    if (title) root.dataset.title = title;
    video.poster = poster || '';
    video.src = src;
    if (vtt) {
      const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
      t.addEventListener('load', captions);
      video.append(t);
    }
    if (seek && duration) seek.max = duration;
    if (dur && duration) dur.textContent = vpFmt(duration);
    video.load();
    state(); sync(); captions();
  };

  if (video.readyState > 0) meta();
  captions(); state(); fsSync();
  video.dispatchEvent(new Event('volumechange'));
  return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}

// Dark streaming players. A player may carry an "up next" rail: each [data-item] button holds data-src,
// data-poster, data-captions, data-title and data-duration, plus data-watched (0 to 1) for the
// continue-watching bar it paints through --lp. The current tile has aria-current.
document.querySelectorAll('[data-vp="dk"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const items = [...root.querySelectorAll('[data-item]')];
  if (!items.length) return;
  const prev = root.querySelector('[data-prev]'), next = root.querySelector('[data-next]');
  const title = root.querySelector('[data-now-title]'), count = root.querySelector('[data-now-count]');
  let index = Math.max(0, items.findIndex(i => i.getAttribute('aria-current') === 'true'));

  const mark = () => {
    items.forEach((it, n) => { if (n === index) it.setAttribute('aria-current', 'true'); else it.removeAttribute('aria-current'); });
    // aria-disabled rather than disabled, so a focused button keeps focus at either end of the list
    if (prev) prev.setAttribute('aria-disabled', String(index === 0));
    if (next) next.setAttribute('aria-disabled', String(index === items.length - 1));
    if (title) title.textContent = items[index].dataset.title;
    if (count) count.textContent = count.dataset.label.replace('{n}', index + 1).replace('{total}', items.length);
  };
  const progress = (it, share) => {
    const watched = Math.max(+it.dataset.watched || 0, share);
    it.dataset.watched = watched;
    it.style.setProperty('--lp', watched);
    const state = watched > 0.97 ? 'Completed' : watched > 0 ? 'In progress' : 'Not started';
    const label = it.querySelector('[data-state]');
    if (label && label.textContent !== state) label.textContent = state;
    it.toggleAttribute('data-done', state === 'Completed');
  };
  const select = (n, play) => {
    if (n < 0 || n >= items.length) return;
    index = n;
    const it = items[n];
    player.load({ src: it.dataset.src, poster: it.dataset.poster, captions: it.dataset.captions, title: it.dataset.title, duration: +it.dataset.duration });
    // continue watching: resume where the visitor left off unless the video was finished
    const resume = +it.dataset.watched || 0;
    if (resume > 0 && resume < 0.97) video.addEventListener('loadedmetadata', () => player.jump(resume * video.duration), { once: true });
    mark();
    if (play) player.start();
  };

  player.onTime((t, d) => { if (d) progress(items[index], t / d); });
  video.addEventListener('ended', () => { progress(items[index], 1); select(index + 1, true); });
  items.forEach((it, n) => it.addEventListener('click', () => select(n, true)));
  if (prev) prev.addEventListener('click', () => select(index - 1, !video.paused));
  if (next) next.addEventListener('click', () => select(index + 1, !video.paused));
  mark();
});
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#9aa3b5] uppercase">Streaming player with an up next rail</span>
<div class="group/vp text-[#f1f5f9] [--b:0] [--p:0] [--v:1] focus:outline-none" data-vp="dk" data-title="Fractal Tide" data-autohide role="group" aria-label="Northlight Studies player" tabindex="-1">
  <div class="relative isolate overflow-hidden rounded-[14px] bg-black shadow-[0_24px_60px_rgba(0,0,0,.55)] group-data-fullscreen/vp:rounded-none group-data-idle/vp:cursor-none" data-stage>
    <video class="block aspect-video h-auto w-full object-cover group-data-fullscreen/vp:h-full group-data-fullscreen/vp:object-contain" src="../media/clip-1.mp4" poster="../media/clip-1-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-1-captions.vtt" srclang="en" label="English" default>
    </video>
    <p class="pointer-events-none absolute bottom-[100px] left-1/2 z-[1] m-0 w-max max-w-[86%] -translate-x-1/2 rounded-md bg-black/80 px-[.8em] py-[.3em] text-center text-[clamp(.85rem,2.4vw,1.15rem)] leading-[1.4] whitespace-pre-line text-white group-data-idle/vp:bottom-5 max-[560px]:bottom-[92px] max-[560px]:group-data-idle/vp:bottom-5" data-cc-box hidden></p>
    <div class="pointer-events-none absolute inset-x-0 top-0 z-[2] hidden bg-[linear-gradient(rgba(0,0,0,.75),transparent)] px-[22px] pt-[18px] pb-[50px] transition-opacity duration-300 group-data-ready/vp:block group-data-idle/vp:opacity-0 motion-reduce:transition-none max-[560px]:px-3.5 max-[560px]:pt-3 max-[560px]:pb-10"><p class="text-[.78rem] font-bold tracking-[.12em] text-[#fda4af] uppercase max-[560px]:hidden">Northlight Studies · Season 1</p><h3 class="mt-1 text-[1.35rem] font-bold tracking-[-.01em] max-[560px]:text-[1.05rem]" data-now-title>Fractal Tide</h3></div>
    <div class="absolute top-1/2 left-1/2 z-[2] hidden -translate-x-1/2 -translate-y-1/2 items-center gap-[22px] transition-opacity duration-300 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none max-[560px]:gap-3.5">
      <button type="button" class="grid size-[52px] cursor-pointer place-items-center rounded-full bg-black/45 hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-skip="-10" aria-label="Back 10 seconds"><svg class="size-7" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="grid size-[76px] cursor-pointer place-items-center rounded-full bg-[#e11d48] shadow-[0_10px_30px_rgba(225,29,72,.45)] hover:bg-[#be123c] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:size-[54px]" data-play aria-label="Play Fractal Tide"><svg class="block size-[34px] group-data-playing/vp:hidden max-[560px]:size-[26px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-[34px] group-data-playing/vp:block max-[560px]:size-[26px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
      <button type="button" class="grid size-[52px] cursor-pointer place-items-center rounded-full bg-black/45 hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-skip="10" aria-label="Forward 10 seconds"><svg class="size-7" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
    </div>
    <div class="absolute inset-x-0 bottom-0 z-[2] hidden bg-[linear-gradient(to_top,rgba(0,0,0,.88),rgba(0,0,0,.5)_60%,transparent)] px-3.5 pt-10 pb-2.5 transition-opacity duration-300 group-data-ready/vp:block group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
      <input type="range" class="vd-range w-full" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
      <div class="mt-1.5 flex items-center gap-0.5">
        <button type="button" class="group/m grid size-10 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-[22px] group-aria-pressed/m:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="hidden size-[22px] group-aria-pressed/m:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
        <input type="range" class="vd-range w-20 flex-none [--vd-f:var(--v)] [--vd-fb:0] max-[760px]:hidden" data-vol min="0" max="1" step="0.05" value="1" aria-label="Volume" aria-valuetext="100%">
        <span class="mr-auto px-2 text-[.84rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
        <button type="button" class="grid h-10 min-w-12 flex-none cursor-pointer place-items-center rounded-full px-2.5 text-[.84rem] font-bold tabular-nums hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-speed><span class="sr-only">Playback speed </span><span data-speed-val>1×</span></button>
        <button type="button" class="grid size-10 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white aria-pressed:bg-[rgba(225,29,72,.3)]" data-cc aria-label="Captions" aria-pressed="true"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
        <button type="button" class="grid size-10 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-next aria-label="Next episode"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="16.4" y="5" width="2.6" height="14" rx="1"/><path d="M5 6.2v11.6a1 1 0 0 0 1.53.85l8.07-5.55a1.3 1.3 0 0 0 0-2.2L6.53 5.35A1 1 0 0 0 5 6.2z"/></svg></button>
        <button type="button" class="grid size-10 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/14 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-[22px] group-data-fullscreen/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="hidden size-[22px] group-data-fullscreen/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
      </div>
    </div>
  </div>
  <div class="mx-0.5 mt-[22px] mb-3 flex items-baseline justify-between"><h3 class="text-[1.05rem] font-bold">Up next</h3><p class="text-[.82rem] text-[#a1a9b8]" data-now-count data-label="Episode {n} of {total}">Episode 1 of 4</p></div>
  <ol class="grid grid-cols-4 gap-3.5 max-[760px]:grid-cols-2" aria-label="Episodes">
    <li><button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-xl bg-[#151922] text-left [--lp:0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-[current=true]:shadow-[0_0_0_2px_#e11d48]" style="--lp:0" data-item data-watched="0" data-src="../media/clip-1.mp4" data-poster="../media/clip-1-poster.jpg" data-captions="../media/clip-1-captions.vtt" data-title="Fractal Tide" data-duration="16" aria-current="true">
      <img class="block aspect-video h-auto w-full object-cover opacity-85 transition-opacity duration-200 group-hover/t:opacity-100 motion-reduce:transition-none" src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="block h-[3px] bg-[linear-gradient(to_right,#e11d48_calc(var(--lp)*100%),#3a4150_0)]" aria-hidden="true"></span>
      <span class="block px-3 pt-[9px] pb-3"><small class="block text-[.74rem] text-[#a1a9b8] group-aria-[current=true]/t:text-[#fda4af]">Episode 1 · 0:16</small><b class="mt-0.5 block text-[.9rem]">Fractal Tide</b></span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span></button></li>
    <li><button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-xl bg-[#151922] text-left [--lp:0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-[current=true]:shadow-[0_0_0_2px_#e11d48]" style="--lp:0.45" data-item data-watched="0.45" data-src="../media/clip-2.mp4" data-poster="../media/clip-2-poster.jpg" data-captions="../media/clip-2-captions.vtt" data-title="Rule 30 Loom" data-duration="20">
      <img class="block aspect-video h-auto w-full object-cover opacity-85 transition-opacity duration-200 group-hover/t:opacity-100 motion-reduce:transition-none" src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="block h-[3px] bg-[linear-gradient(to_right,#e11d48_calc(var(--lp)*100%),#3a4150_0)]" aria-hidden="true"></span>
      <span class="block px-3 pt-[9px] pb-3"><small class="block text-[.74rem] text-[#a1a9b8] group-aria-[current=true]/t:text-[#fda4af]">Episode 2 · 0:20</small><b class="mt-0.5 block text-[.9rem]">Rule 30 Loom</b></span><span class="sr-only">, </span><span class="sr-only" data-state>In progress</span></button></li>
    <li><button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-xl bg-[#151922] text-left [--lp:0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-[current=true]:shadow-[0_0_0_2px_#e11d48]" style="--lp:1" data-item data-watched="1" data-done data-src="../media/clip-3.mp4" data-poster="../media/clip-3-poster.jpg" data-captions="../media/clip-3-captions.vtt" data-title="Life in Colour" data-duration="14">
      <img class="block aspect-video h-auto w-full object-cover opacity-85 transition-opacity duration-200 group-hover/t:opacity-100 motion-reduce:transition-none" src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="block h-[3px] bg-[linear-gradient(to_right,#e11d48_calc(var(--lp)*100%),#3a4150_0)]" aria-hidden="true"></span>
      <span class="block px-3 pt-[9px] pb-3"><small class="block text-[.74rem] text-[#a1a9b8] group-aria-[current=true]/t:text-[#fda4af]">Episode 3 · 0:14</small><b class="mt-0.5 block text-[.9rem]">Life in Colour</b></span><span class="sr-only">, </span><span class="sr-only" data-state>Completed</span></button></li>
    <li><button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-xl bg-[#151922] text-left [--lp:0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-[current=true]:shadow-[0_0_0_2px_#e11d48]" style="--lp:0" data-item data-watched="0" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-duration="12">
      <img class="block aspect-video h-auto w-full object-cover opacity-85 transition-opacity duration-200 group-hover/t:opacity-100 motion-reduce:transition-none" src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="block h-[3px] bg-[linear-gradient(to_right,#e11d48_calc(var(--lp)*100%),#3a4150_0)]" aria-hidden="true"></span>
      <span class="block px-3 pt-[9px] pb-3"><small class="block text-[.74rem] text-[#a1a9b8] group-aria-[current=true]/t:text-[#fda4af]">Episode 4 · 0:12</small><b class="mt-0.5 block text-[.9rem]">Slow Gradients</b></span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span></button></li>
  </ol>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#9aa3b5] uppercase">Compact dark card</span>
<div class="group/vp max-w-[440px] overflow-hidden rounded-[20px] border border-[#262c38] bg-[#151922] text-[#f1f5f9] [--b:0] [--p:0] [--v:1] focus:outline-none" data-vp="dk" data-title="Life in Colour" data-captions role="group" aria-label="Life in Colour player" tabindex="-1">
  <div class="relative isolate overflow-hidden bg-black" data-stage>
    <video class="block aspect-video h-auto w-full object-cover group-data-fullscreen/vp:h-full group-data-fullscreen/vp:object-contain" src="../media/clip-3.mp4" poster="../media/clip-3-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-3-captions.vtt" srclang="en" label="English" default>
    </video>
    <p class="pointer-events-none absolute bottom-3.5 left-1/2 z-[1] m-0 w-max max-w-[86%] -translate-x-1/2 rounded-md bg-black/80 px-[.8em] py-[.3em] text-center text-[clamp(.85rem,2.4vw,1.15rem)] leading-[1.4] whitespace-pre-line text-white" data-cc-box hidden></p>
    <div class="absolute top-1/2 left-1/2 z-[2] hidden -translate-x-1/2 -translate-y-1/2 group-data-ready/vp:flex"><button type="button" class="grid size-16 cursor-pointer place-items-center rounded-full bg-[#e11d48] shadow-[0_10px_30px_rgba(225,29,72,.45)] hover:bg-[#be123c] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-play aria-label="Play Life in Colour"><svg class="block size-7 group-data-playing/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-7 group-data-playing/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button></div>
  </div>
  <div class="hidden items-center gap-0.5 px-2.5 pt-2 pb-1 group-data-ready/vp:flex">
    <span class="px-2 text-[.84rem] whitespace-nowrap tabular-nums" data-cur>0:00</span>
    <input type="range" class="vd-range mx-1 flex-1" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
    <span class="px-2 text-[.84rem] whitespace-nowrap tabular-nums" data-dur>0:14</span>
    <button type="button" class="group/m grid size-10 flex-none cursor-pointer place-items-center rounded-full hover:bg-[#232936] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-[22px] group-aria-pressed/m:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="hidden size-[22px] group-aria-pressed/m:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
    <button type="button" class="grid size-10 flex-none cursor-pointer place-items-center rounded-full hover:bg-[#232936] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-[22px] group-data-fullscreen/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="hidden size-[22px] group-data-fullscreen/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
  </div>
  <div class="px-5 pt-2 pb-5">
    <h3 class="text-[1.15rem] font-bold">Life in Colour</h3>
    <p class="mt-1.5 text-[.88rem] leading-[1.5] text-[#a1a9b8]">Conway's Game of Life on a 240 by 135 grid, with dying cells fading from pink to deep indigo. Captions describe the score.</p>
    <ul class="mt-3 flex flex-wrap gap-1.5" aria-label="Details"><li class="rounded-full border border-[#3a4150] px-2.5 py-[3px] text-[.75rem] text-[#cbd5e1]">Study 03</li><li class="rounded-full border border-[#3a4150] px-2.5 py-[3px] text-[.75rem] text-[#cbd5e1]">0:14</li><li class="rounded-full border border-[#3a4150] px-2.5 py-[3px] text-[.75rem] text-[#cbd5e1]">Captions</li><li class="rounded-full border border-[#3a4150] px-2.5 py-[3px] text-[.75rem] text-[#cbd5e1]">Original score</li></ul>
  </div>
</div></div>
CSS
/* Range inputs need vendor pseudo-elements, so they get one small utility. --f is the filled share
   (defaults to --p, the played share) and --fb the buffered share; both are set by the script. */
@utility vd-range {
  -webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 16px; margin: 0; background: none; cursor: pointer;
  --f: var(--vd-f, var(--p)); --fb: var(--vd-fb, var(--b));
  --fill: linear-gradient(to right, #e11d48 0 calc(7px + (100% - 14px) * var(--f)), rgb(255 255 255 / .6) 0 calc(7px + (100% - 14px) * var(--fb)), rgb(255 255 255 / .35) 0);
  &:focus-visible { outline: none; }
  &::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--fill); }
  &::-moz-range-track { height: 4px; border-radius: 99px; background: var(--fill); }
  &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 0; border-radius: 50%; background: #e11d48; }
  &::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #e11d48; }
  &:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px #000, 0 0 0 5px #fff; }
  &:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #000, 0 0 0 5px #fff; }
}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

  const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
  const sync = () => {
    const t = video.currentTime, d = length();
    root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
    if (cur) cur.textContent = vpFmt(t);
    if (seek) {
      seek.value = t;
      const text = vpFmt(t) + ' of ' + vpFmt(d);
      if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
    }
    listeners.forEach(fn => fn(t, d));
  };
  const buffered = () => {
    const b = video.buffered, d = length();
    let end = 0;
    for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
    root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
  };
  const tick = () => { sync(); raf = requestAnimationFrame(tick); };
  const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
  const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
  const start = () => {
    // one player at a time; muted background loops (data-bg) keep running
    document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
    return video.play().catch(() => {});
  };
  const toggle = () => (video.paused || video.ended ? start() : video.pause());
  const name = () => root.dataset.title || 'video';
  const state = () => {
    const playing = !video.paused;
    root.toggleAttribute('data-playing', playing);
    plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
    // if the focused play button was just hidden (a big centre button), hand focus to a visible one
    const a = document.activeElement;
    if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
  };

  // Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
  // so the text sits above the control bar. Players without a box fall back to native rendering.
  // A player without a captions button shows captions when the root has data-captions.
  const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
  const render = () => {
    if (!ccBox) return;
    const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
    const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
    ccBox.textContent = text;
    ccBox.hidden = !text;
  };
  const captions = () => {
    track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
    if (cc) cc.hidden = !track;
    if (!track) return render();
    track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
    track.oncuechange = render;
    render();
  };

  // Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
  const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
  function wake() {
    root.removeAttribute('data-idle');
    clearTimeout(idle);
    if (!root.hasAttribute('data-autohide') || video.paused) return;
    idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
  }

  const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
  const fullscreen = () => {
    if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
    else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
    else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
    else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
  };
  const fsSync = () => {
    const on = fsElement() === stage;
    root.toggleAttribute('data-fullscreen', on);
    if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
  };

  plays.forEach(b => b.addEventListener('click', toggle));
  root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
  root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
  video.addEventListener('click', () => {
    // a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
    if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
    toggle();
    if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
  });
  video.addEventListener('play', () => { state(); tick(); wake(); });
  video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
  video.addEventListener('loadedmetadata', () => { meta(); captions(); });
  video.addEventListener('progress', buffered);
  video.addEventListener('seeked', () => { sync(); buffered(); });
  video.addEventListener('volumechange', () => {
    const v = video.muted ? 0 : video.volume;
    root.style.setProperty('--v', v);
    if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
    if (mute) mute.setAttribute('aria-pressed', String(v === 0));
  });
  video.addEventListener('ratechange', () => {
    if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
  });
  video.textTracks.addEventListener('addtrack', captions);
  video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
  if (seek) seek.addEventListener('input', () => jump(+seek.value));
  if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
  if (mute) mute.addEventListener('click', () => {
    if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
    else video.muted = true;
  });
  if (cc) cc.addEventListener('click', () => {
    cc.setAttribute('aria-pressed', String(!ccOn()));
    if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
    render();
  });
  if (speed) speed.addEventListener('click', () => {
    const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
    video.playbackRate = video.defaultPlaybackRate = rate;
  });
  if (fs) {
    fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
    fs.addEventListener('click', fullscreen);
    document.addEventListener('fullscreenchange', fsSync);
    document.addEventListener('webkitfullscreenchange', fsSync);
  }
  if (pip) {
    pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
    pip.addEventListener('click', () => (document.pictureInPictureElement === video
      ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
    video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
    video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
  }
  ['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
  root.addEventListener('pointerleave', () => {
    if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
    clearTimeout(idle);
    idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
  });
  root.addEventListener('keydown', e => {
    if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
    const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
    wake();
    if (k === 'k' || (k === ' ' && !control)) toggle();
    else if (k === 'm') video.muted = !video.muted;
    else if (k === 'f' && fs && !fs.hidden) fullscreen();
    else if (k === 'c' && cc && !cc.hidden) cc.click();
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
      jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
    else return;
    e.preventDefault();
  });

  // load() swaps the source, poster and captions file (playlists and lightboxes use it)
  const load = ({ src, poster, captions: vtt, title, duration }) => {
    video.pause();
    video.querySelectorAll('track').forEach(t => t.remove());
    if (title) root.dataset.title = title;
    video.poster = poster || '';
    video.src = src;
    if (vtt) {
      const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
      t.addEventListener('load', captions);
      video.append(t);
    }
    if (seek && duration) seek.max = duration;
    if (dur && duration) dur.textContent = vpFmt(duration);
    video.load();
    state(); sync(); captions();
  };

  if (video.readyState > 0) meta();
  captions(); state(); fsSync();
  video.dispatchEvent(new Event('volumechange'));
  return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}

// Dark streaming players. A player may carry an "up next" rail: each [data-item] button holds data-src,
// data-poster, data-captions, data-title and data-duration, plus data-watched (0 to 1) for the
// continue-watching bar it paints through --lp. The current tile has aria-current.
document.querySelectorAll('[data-vp="dk"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const items = [...root.querySelectorAll('[data-item]')];
  if (!items.length) return;
  const prev = root.querySelector('[data-prev]'), next = root.querySelector('[data-next]');
  const title = root.querySelector('[data-now-title]'), count = root.querySelector('[data-now-count]');
  let index = Math.max(0, items.findIndex(i => i.getAttribute('aria-current') === 'true'));

  const mark = () => {
    items.forEach((it, n) => { if (n === index) it.setAttribute('aria-current', 'true'); else it.removeAttribute('aria-current'); });
    // aria-disabled rather than disabled, so a focused button keeps focus at either end of the list
    if (prev) prev.setAttribute('aria-disabled', String(index === 0));
    if (next) next.setAttribute('aria-disabled', String(index === items.length - 1));
    if (title) title.textContent = items[index].dataset.title;
    if (count) count.textContent = count.dataset.label.replace('{n}', index + 1).replace('{total}', items.length);
  };
  const progress = (it, share) => {
    const watched = Math.max(+it.dataset.watched || 0, share);
    it.dataset.watched = watched;
    it.style.setProperty('--lp', watched);
    const state = watched > 0.97 ? 'Completed' : watched > 0 ? 'In progress' : 'Not started';
    const label = it.querySelector('[data-state]');
    if (label && label.textContent !== state) label.textContent = state;
    it.toggleAttribute('data-done', state === 'Completed');
  };
  const select = (n, play) => {
    if (n < 0 || n >= items.length) return;
    index = n;
    const it = items[n];
    player.load({ src: it.dataset.src, poster: it.dataset.poster, captions: it.dataset.captions, title: it.dataset.title, duration: +it.dataset.duration });
    // continue watching: resume where the visitor left off unless the video was finished
    const resume = +it.dataset.watched || 0;
    if (resume > 0 && resume < 0.97) video.addEventListener('loadedmetadata', () => player.jump(resume * video.duration), { once: true });
    mark();
    if (play) player.start();
  };

  player.onTime((t, d) => { if (d) progress(items[index], t / d); });
  video.addEventListener('ended', () => { progress(items[index], 1); select(index + 1, true); });
  items.forEach((it, n) => it.addEventListener('click', () => select(n, true)));
  if (prev) prev.addEventListener('click', () => select(index - 1, !video.paused));
  if (next) next.addEventListener('click', () => select(index + 1, !video.paused));
  mark();
});
})();

About this set

Two dark players in the style of a streaming app. The streaming player shows the series name and episode title at the top, a centre cluster with back 10 seconds, a large rose play button and forward 10 seconds, and a bottom bar with a seek slider, mute and volume, time, playback speed, captions, next episode and full screen; the overlays fade out together during playback. Under the player an up next rail lists four episodes as thumbnail tiles with their number, length and a continue-watching bar. Selecting a tile loads that episode and resumes from where the bar says the visitor left off, unless it was finished, and the next episode starts when one ends. The compact dark card puts a player with a centre play button into a card with a seek row, mute and full screen, a title, a description and detail chips, for related videos in a sidebar or a feed. Colours are chosen for dark interfaces: muted text stays above 7 to 1 on the page and slider rails stay visible against the video scrim. The Bootstrap version runs under data-bs-theme set to dark.

What’s included

  • Title overlay, centre controls and a full bottom bar
  • Up next rail with continue-watching bars and resume
  • Next episode button and automatic next episode
  • Compact dark card with captions always on
  • Bootstrap version under data-bs-theme dark

Accessibility

All controls are named buttons and sliders, the episode tiles carry their number, title, length and a watched status in their name, and the current tile has aria-current. Captions are on by default. Focus shows a white outline that stands out on the dark surfaces, and the overlay fades are removed under prefers-reduced-motion.

Customise it

Change --vd-accent, --vd-surface and --vd-muted on .vd for the rose accent, card surface and secondary text. Bootstrap restyles .btn and .form-range through custom properties under data-bs-theme dark; in Tailwind edit the #e11d48 values and the vd-range utility.