Open demo

CSS code

HTML
<div><span class="lbl">Course player with a lesson list</span>
<div class="vl vl-course" data-vp="pl" data-title="Fractal Tide" data-autohide role="group" aria-label="Generative Motion course player" tabindex="-1">
  <div>
    <div class="vl-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="vl-cc-box" data-cc-box hidden></p>
      <div class="vl-bar">
        <input type="range" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
        <button type="button" class="vl-btn" 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="vl-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>
        <span class="vl-time"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
        <span class="vl-spacer"></span>
        <button type="button" class="vl-btn vl-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="vl-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 class="vl-head">
      <div><p class="vl-kicker" data-now-count data-label="Lesson {n} of {total}">Lesson 1 of 4</p><h3 data-now-title>Fractal Tide</h3></div>
      <button type="button" class="vl-step" data-prev aria-label="Previous lesson" aria-disabled="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M19 6.2v11.6a1 1 0 0 1-1.53.85L9.4 13.1a1.3 1.3 0 0 1 0-2.2l8.07-5.55A1 1 0 0 1 19 6.2z"/></svg><span>Previous</span></button>
      <button type="button" class="vl-step" data-next aria-label="Next lesson"><span>Next</span><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>
    </div>
  </div>
  <section class="vl-list" aria-labelledby="vl-course-title">
    <header><h3 id="vl-course-title">Generative Motion, Module 1</h3><p>4 lessons · 1 min 2 s · Patterns from simple rules</p></header>
    <ol>
      <li><button type="button" class="vl-item" data-item 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">
        <span class="vl-num" aria-hidden="true"><b>1</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name">Fractal Tide<small>Zooming into the Mandelbrot set</small></span>
        <span class="vl-len"><span class="vl-sr">Length </span>0:16</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
      <li><button type="button" class="vl-item" data-item 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">
        <span class="vl-num" aria-hidden="true"><b>2</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name">Rule 30 Loom<small>One row of cells, one rule</small></span>
        <span class="vl-len"><span class="vl-sr">Length </span>0:20</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
      <li><button type="button" class="vl-item" data-item 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">
        <span class="vl-num" aria-hidden="true"><b>3</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name">Life in Colour<small>Conway's Game of Life</small></span>
        <span class="vl-len"><span class="vl-sr">Length </span>0:14</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
      <li><button type="button" class="vl-item" data-item 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">
        <span class="vl-num" aria-hidden="true"><b>4</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name">Slow Gradients<small>Colour fields that drift</small></span>
        <span class="vl-len"><span class="vl-sr">Length </span>0:12</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
    </ol>
  </section>
</div></div>

<div><span class="lbl">Playlist with a thumbnail strip</span>
<div class="vl" data-vp="pl" data-title="Life in Colour" data-autohide role="group" aria-label="Northlight Studies playlist" tabindex="-1">
  <div class="vl-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="vl-cc-box" data-cc-box hidden></p>
    <div class="vl-bar">
      <input type="range" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
      <button type="button" class="vl-btn" data-prev aria-label="Previous video"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M19 6.2v11.6a1 1 0 0 1-1.53.85L9.4 13.1a1.3 1.3 0 0 1 0-2.2l8.07-5.55A1 1 0 0 1 19 6.2z"/></svg></button>
      <button type="button" class="vl-btn" 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>
      <button type="button" class="vl-btn" data-next aria-label="Next video"><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>
      <span class="vl-time"><span data-cur>0:00</span> / <span data-dur>0:14</span></span>
      <span class="vl-spacer"></span>
      <button type="button" class="vl-btn vl-hide-sm" 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="vl-btn vl-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="vl-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 class="vl-strip-head"><h3 data-now-title>Life in Colour</h3><p data-now-count data-label="Video {n} of {total}">Video 1 of 4</p></div>
  <ol class="vl-strip" aria-label="Playlist">
    <li><button type="button" class="vl-thumb" data-item 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" aria-current="true">
      <span class="vl-img"><img src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len"><span class="vl-sr">Length </span>0:14</span></span><span class="vl-name">Life in Colour</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span></button></li>
    <li><button type="button" class="vl-thumb" data-item 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">
      <span class="vl-img"><img src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len"><span class="vl-sr">Length </span>0:16</span></span><span class="vl-name">Fractal Tide</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span></button></li>
    <li><button type="button" class="vl-thumb" data-item 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">
      <span class="vl-img"><img src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len"><span class="vl-sr">Length </span>0:12</span></span><span class="vl-name">Slow Gradients</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span></button></li>
    <li><button type="button" class="vl-thumb" data-item 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">
      <span class="vl-img"><img src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len"><span class="vl-sr">Length </span>0:20</span></span><span class="vl-name">Rule 30 Loom</span><span class="vl-sr">, </span><span class="vl-sr" data-state>Not started</span></button></li>
  </ol>
</div></div>
CSS
/* Playlist and course players. Each [data-item] button holds a video's src, poster, captions, title and duration;
   the script loads it into the one <video>, sets aria-current, and writes each item's watched share to --lp. */
.vl{--vl-accent:#7c3aed;--vl-deep:#6d28d9;--vl-ink:#1e1b2e;--vl-muted:#5f5873;--vl-line:#e4def3;--p:0;--b:0;color:var(--vl-ink)}
.vl:focus{outline:none}
.vl [hidden]{display:none !important}
.vl button{font:inherit;color:inherit;cursor:pointer}
.vl-stage{position:relative;overflow:hidden;border-radius:18px;background:#120d24;color:#fff;isolation:isolate;box-shadow:0 18px 40px rgba(60,30,120,.16)}
.vl video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;background:#120d24}
.vl-stage:fullscreen{border-radius:0}
.vl-stage:fullscreen video{height:100%;object-fit:contain}
.vl:not([data-ready]) .vl-bar{display:none}
.vl[data-idle] .vl-stage{cursor:none}
.vl-btn{position:relative;flex:none;display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:10px;background:none}
.vl-btn:hover{background:rgba(255,255,255,.16)}
.vl-btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vl-btn svg{display:block;width:22px;height:22px}
.vl-btn[aria-disabled="true"]{opacity:.4;cursor:default}
.vl-btn[aria-disabled="true"]:hover{background:none}
.vl .i-pause,.vl[data-playing] .i-play,.vl .i-shrink,.vl[data-fullscreen] .i-expand,.vl .i-muted,.vl [aria-pressed="true"] > .i-vol{display:none}
.vl[data-playing] .i-pause,.vl[data-fullscreen] .i-shrink,.vl [aria-pressed="true"] > .i-muted{display:block}
.vl-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:#c4b5fd}
.vl-time{padding:0 6px;font-size:.82rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.vl-bar{position:absolute;inset:auto 0 0;z-index:2;display:flex;flex-wrap:wrap;align-items:center;gap:2px;padding:30px 12px 8px;
  background:linear-gradient(to top,rgba(18,13,36,.92),rgba(18,13,36,.55) 60%,transparent);transition:opacity .25s}
.vl[data-idle] .vl-bar{opacity:0;pointer-events:none}
.vl-bar input{flex:1 0 100%;margin:0 4px 4px}
.vl-spacer{flex:1}
.vl input[type="range"]{-webkit-appearance:none;appearance:none;display:block;min-width:0;height:14px;background:none;cursor:pointer;
  --fill:linear-gradient(to right,#a78bfa 0 calc(7px + (100% - 14px) * var(--p)),rgba(255,255,255,.75) 0 calc(7px + (100% - 14px) * var(--b)),rgba(255,255,255,.45) 0)}
.vl input[type="range"]:focus-visible{outline:none}
.vl input[type="range"]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vl input[type="range"]::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vl input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:0;border-radius:50%;background:#fff}
.vl input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:0;border-radius:50%;background:#fff}
.vl input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #7c3aed,0 0 0 5px #fff}
.vl input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #7c3aed,0 0 0 5px #fff}
.vl-cc-box{position:absolute;left:50%;bottom:96px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;margin:0;padding:.3em .75em;border-radius:8px;
  background:rgba(18,13,36,.86);color:#fff;font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;text-align:center;white-space:pre-line;pointer-events:none}
.vl[data-idle] .vl-cc-box{bottom:18px}
/* 1. course player: lesson list beside the video */
.vl-course{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(0,1fr);gap:20px;align-items:start}
.vl-head{display:flex;align-items:center;gap:12px;margin-top:16px}
.vl-head > div{flex:1;min-width:0}
.vl-kicker{font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--vl-deep)}
.vl-head h3{margin-top:2px;font-size:1.3rem;letter-spacing:-.01em}
.vl-step{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 14px;border:1px solid #cfc6e8;border-radius:99px;background:#fff;font-size:.88rem;font-weight:600}
.vl-step svg{width:18px;height:18px}
.vl-step:hover{border-color:var(--vl-deep);color:var(--vl-deep)}
.vl-step:focus-visible{outline:2px solid var(--vl-deep);outline-offset:2px}
.vl-step[aria-disabled="true"]{opacity:.45;cursor:default;border-color:#cfc6e8;color:inherit}
.vl-list{border:1px solid var(--vl-line);border-radius:18px;background:#fff;overflow:hidden}
.vl-list header{padding:16px 18px;border-bottom:1px solid var(--vl-line)}
.vl-list header h3{font-size:1rem}
.vl-list header p{margin-top:3px;font-size:.82rem;color:var(--vl-muted)}
.vl-list ol{list-style:none;padding:6px}
.vl-item{--lp:0;position:relative;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:4px 12px;width:100%;padding:10px 12px;border:0;border-radius:12px;background:none;text-align:left}
.vl-item:hover{background:#f6f3fd}
.vl-item:focus-visible{outline:2px solid var(--vl-deep);outline-offset:-2px}
.vl-item[aria-current="true"]{background:#ede7fb}
.vl-num{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:1.5px solid #8b80a8;font-size:.8rem;font-weight:700}
.vl-num svg{display:none;width:16px;height:16px}
.vl-item[aria-current="true"] .vl-num{border-color:var(--vl-deep);background:var(--vl-deep);color:#fff}
.vl-item[data-done] .vl-num{border-color:#15803d;background:#15803d;color:#fff}
.vl-item[data-done] .vl-num b{display:none}
.vl-item[data-done] .vl-num svg{display:block}
.vl-name{font-size:.92rem;font-weight:600;line-height:1.3}
.vl-name small{display:block;font-size:.76rem;font-weight:500;color:var(--vl-muted)}
.vl-len{font-size:.8rem;color:var(--vl-muted);font-variant-numeric:tabular-nums}
.vl-prog{grid-column:2 / -1;height:3px;border-radius:99px;background:linear-gradient(to right,var(--vl-deep) calc(var(--lp) * 100%),#ddd5ef 0)}
.vl-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* 2. playlist with a thumbnail strip */
.vl-strip-head{display:flex;align-items:baseline;justify-content:space-between;margin:16px 2px 10px}
.vl-strip-head h3{font-size:1.05rem}
.vl-strip-head p{font-size:.82rem;color:var(--vl-muted)}
.vl-strip{display:flex;gap:12px;overflow-x:auto;padding:4px 4px 10px;list-style:none;scroll-snap-type:x mandatory;scrollbar-width:thin}
.vl-strip li{flex:1 0 200px;scroll-snap-align:start}
.vl-thumb{--lp:0;position:relative;display:block;width:100%;padding:0;border:0;background:none;text-align:left}
.vl-thumb .vl-img{position:relative;display:block;overflow:hidden;border-radius:12px;background:#120d24}
.vl-thumb img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;transition:transform .3s}
.vl-thumb:hover img{transform:scale(1.04)}
.vl-thumb .vl-len{position:absolute;right:6px;bottom:8px;padding:1px 6px;border-radius:6px;background:rgba(18,13,36,.85);color:#fff;font-size:.74rem}
.vl-thumb .vl-img::after{content:"";position:absolute;inset:auto 0 0;height:4px;background:linear-gradient(to right,#a78bfa calc(var(--lp) * 100%),transparent 0)}
.vl-thumb .vl-name{display:block;margin-top:8px;font-size:.88rem}
.vl-thumb:focus-visible{outline:none}
.vl-thumb:focus-visible .vl-img{outline:2px solid var(--vl-deep);outline-offset:2px}
.vl-thumb[aria-current="true"] .vl-img{box-shadow:0 0 0 3px var(--vl-accent)}
.vl-thumb[aria-current="true"] .vl-name{color:var(--vl-deep)}
@media (max-width:820px){.vl-course{grid-template-columns:1fr}}
@media (max-width:560px){.vl-hide-sm{display:none}.vl-cc-box{bottom:92px}.vl-head h3{font-size:1.1rem}.vl-step span{display:none}.vl-step{padding:0 10px}.vl-strip li{flex:0 0 160px}}
@media (prefers-reduced-motion:reduce){.vl-bar,.vl-thumb img{transition:none}.vl-thumb:hover img{transform: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) };
}

// Playlists. Each [data-item] button carries data-src, data-poster, data-captions, data-title and
// data-duration; selecting one loads it into the player. The current item has aria-current, each item
// keeps its own watched share in --lp, and the next item starts when one ends.
document.querySelectorAll('[data-vp="pl"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const items = [...root.querySelectorAll('[data-item]')];
  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 });
    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">Course player with a lesson list</span>
<div class="vl vl-course" data-vp="pl" data-title="Fractal Tide" data-autohide role="group" aria-label="Generative Motion course player" tabindex="-1">
  <div>
    <div class="vl-stage position-relative overflow-hidden rounded-4 text-white" 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="vl-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
      <div class="vl-bar position-absolute bottom-0 start-0 end-0 d-flex flex-wrap align-items-center gap-1">
        <input type="range" class="form-range d-block w-100 mx-1 mb-1" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
        <button type="button" class="btn" 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" 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>
        <span class="vl-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 vl-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" 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 class="d-flex align-items-center gap-2 mt-3">
      <div class="flex-grow-1 min-w-0"><p class="vl-kicker fw-bold text-uppercase mb-0" data-now-count data-label="Lesson {n} of {total}">Lesson 1 of 4</p><h3 class="fs-5 fw-bold mb-0" data-now-title>Fractal Tide</h3></div>
      <button type="button" class="btn btn-outline-secondary bg-white text-body rounded-pill vl-step d-inline-flex align-items-center gap-1" data-prev aria-label="Previous lesson" aria-disabled="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M19 6.2v11.6a1 1 0 0 1-1.53.85L9.4 13.1a1.3 1.3 0 0 1 0-2.2l8.07-5.55A1 1 0 0 1 19 6.2z"/></svg><span>Previous</span></button>
      <button type="button" class="btn btn-outline-secondary bg-white text-body rounded-pill vl-step d-inline-flex align-items-center gap-1" data-next aria-label="Next lesson"><span>Next</span><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>
    </div>
  </div>
  <section class="vl-list card overflow-hidden" aria-labelledby="vl-course-title">
    <header class="card-header bg-white py-3 px-3"><h3 class="fs-6 fw-bold mb-1" id="vl-course-title">Generative Motion, Module 1</h3><p class="small text-secondary-emphasis mb-0">4 lessons · 1 min 2 s · Patterns from simple rules</p></header>
    <ol class="list-group list-group-flush list-unstyled p-1 mb-0">
      <li><button type="button" class="vl-item list-group-item list-group-item-action position-relative px-3 py-2" data-item 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">
        <span class="vl-num d-grid place-items-center rounded-circle fw-bold" style="place-items:center" aria-hidden="true"><b>1</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name fw-semibold">Fractal Tide<small class="d-block fw-medium">Zooming into the Mandelbrot set</small></span>
        <span class="vl-len"><span class="visually-hidden">Length </span>0:16</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
      <li><button type="button" class="vl-item list-group-item list-group-item-action position-relative px-3 py-2" data-item 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">
        <span class="vl-num d-grid place-items-center rounded-circle fw-bold" style="place-items:center" aria-hidden="true"><b>2</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name fw-semibold">Rule 30 Loom<small class="d-block fw-medium">One row of cells, one rule</small></span>
        <span class="vl-len"><span class="visually-hidden">Length </span>0:20</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
      <li><button type="button" class="vl-item list-group-item list-group-item-action position-relative px-3 py-2" data-item 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">
        <span class="vl-num d-grid place-items-center rounded-circle fw-bold" style="place-items:center" aria-hidden="true"><b>3</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name fw-semibold">Life in Colour<small class="d-block fw-medium">Conway's Game of Life</small></span>
        <span class="vl-len"><span class="visually-hidden">Length </span>0:14</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
      <li><button type="button" class="vl-item list-group-item list-group-item-action position-relative px-3 py-2" data-item 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">
        <span class="vl-num d-grid place-items-center rounded-circle fw-bold" style="place-items:center" aria-hidden="true"><b>4</b><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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="vl-name fw-semibold">Slow Gradients<small class="d-block fw-medium">Colour fields that drift</small></span>
        <span class="vl-len"><span class="visually-hidden">Length </span>0:12</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span>
        <i class="vl-prog" aria-hidden="true"></i></button></li>
    </ol>
  </section>
</div></div>

<div><span class="lbl">Playlist with a thumbnail strip</span>
<div class="vl" data-vp="pl" data-title="Life in Colour" data-autohide role="group" aria-label="Northlight Studies playlist" tabindex="-1">
  <div class="vl-stage position-relative overflow-hidden rounded-4 text-white" 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="vl-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
    <div class="vl-bar position-absolute bottom-0 start-0 end-0 d-flex flex-wrap align-items-center gap-1">
      <input type="range" class="form-range d-block w-100 mx-1 mb-1" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
      <button type="button" class="btn" data-prev aria-label="Previous video"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M19 6.2v11.6a1 1 0 0 1-1.53.85L9.4 13.1a1.3 1.3 0 0 1 0-2.2l8.07-5.55A1 1 0 0 1 19 6.2z"/></svg></button>
      <button type="button" class="btn" 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>
      <button type="button" class="btn" data-next aria-label="Next video"><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>
      <span class="vl-time px-2 me-auto text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:14</span></span>
      <button type="button" class="btn vl-hide-sm" 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 vl-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" 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 class="d-flex align-items-baseline justify-content-between mt-3 mb-2 mx-1"><h3 class="fw-bold mb-0" style="font-size:1.05rem" data-now-title>Life in Colour</h3><p class="small text-secondary-emphasis mb-0" data-now-count data-label="Video {n} of {total}">Video 1 of 4</p></div>
  <ol class="vl-strip d-flex gap-2 overflow-x-auto list-unstyled position-relative px-1 pt-1 pb-2 mb-0" aria-label="Playlist">
    <li><button type="button" class="vl-thumb btn p-0 border-0 d-block w-100 text-start position-relative" data-item 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" aria-current="true">
      <span class="vl-img d-block position-relative overflow-hidden rounded-3"><img class="d-block w-100 h-auto ratio-16x9" src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len position-absolute rounded-2"><span class="visually-hidden">Length </span>0:14</span></span><span class="vl-name d-block mt-2 fw-semibold">Life in Colour</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span></button></li>
    <li><button type="button" class="vl-thumb btn p-0 border-0 d-block w-100 text-start position-relative" data-item 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">
      <span class="vl-img d-block position-relative overflow-hidden rounded-3"><img class="d-block w-100 h-auto ratio-16x9" src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len position-absolute rounded-2"><span class="visually-hidden">Length </span>0:16</span></span><span class="vl-name d-block mt-2 fw-semibold">Fractal Tide</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span></button></li>
    <li><button type="button" class="vl-thumb btn p-0 border-0 d-block w-100 text-start position-relative" data-item 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">
      <span class="vl-img d-block position-relative overflow-hidden rounded-3"><img class="d-block w-100 h-auto ratio-16x9" src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len position-absolute rounded-2"><span class="visually-hidden">Length </span>0:12</span></span><span class="vl-name d-block mt-2 fw-semibold">Slow Gradients</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span></button></li>
    <li><button type="button" class="vl-thumb btn p-0 border-0 d-block w-100 text-start position-relative" data-item 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">
      <span class="vl-img d-block position-relative overflow-hidden rounded-3"><img class="d-block w-100 h-auto ratio-16x9" src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vl-len position-absolute rounded-2"><span class="visually-hidden">Length </span>0:20</span></span><span class="vl-name d-block mt-2 fw-semibold">Rule 30 Loom</span><span class="visually-hidden">, </span><span class="visually-hidden" data-state>Not started</span></button></li>
  </ol>
</div></div>
CSS
/* Playlist and course players on Bootstrap 5: .btn controls, .form-range seek, a .card with a .list-group of lessons
   and a scrolling row of thumbnail buttons. The script sets aria-current and writes each item's watched share to --lp. */
.vl{--vl-accent:#7c3aed;--vl-deep:#6d28d9;--vl-muted:#5f5873;--vl-line:#e4def3;--p:0;--b:0}
.vl:focus{outline:none}
.vl-stage{background:#120d24;isolation:isolate;box-shadow:0 18px 40px rgba(60,30,120,.16)}
.vl video{object-fit:cover;background:#120d24}
.vl-stage:fullscreen{border-radius:0 !important}
.vl-stage:fullscreen .ratio{height:100%}
.vl-stage:fullscreen .ratio::before{display:none}
.vl-stage:fullscreen video{object-fit:contain}
.vl:not([data-ready]) .vl-bar{display:none !important}
.vl[data-idle] .vl-stage{cursor:none}
.vl-bar .btn{--bs-btn-color:#fff;--bs-btn-hover-color:#fff;--bs-btn-active-color:#fff;--bs-btn-hover-bg:rgba(255,255,255,.16);--bs-btn-active-bg:rgba(255,255,255,.24);
  --bs-btn-border-width:0;--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-radius:10px;--bs-btn-focus-box-shadow:none;
  position:relative;display:grid;place-items:center;flex:none;width:38px;height:38px}
.vl-bar .btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vl-bar .btn svg{display:block;width:22px;height:22px}
.vl .btn[aria-disabled="true"]{opacity:.4;cursor:default;--bs-btn-hover-bg:transparent}
.vl .btn .i-pause,.vl[data-playing] .btn .i-play,.vl .btn .i-shrink,.vl[data-fullscreen] .btn .i-expand,.vl .btn .i-muted,.vl .btn[aria-pressed="true"] .i-vol{display:none}
.vl[data-playing] .btn .i-pause,.vl[data-fullscreen] .btn .i-shrink,.vl .btn[aria-pressed="true"] .i-muted{display:block}
.vl-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:#c4b5fd}
.vl-time{font-size:.82rem;font-variant-numeric:tabular-nums}
.vl-bar{z-index:2;padding:30px 12px 8px;background:linear-gradient(to top,rgba(18,13,36,.92),rgba(18,13,36,.55) 60%,transparent);transition:opacity .25s}
.vl[data-idle] .vl-bar{opacity:0;pointer-events:none}
.vl .form-range{height:14px;--fill:linear-gradient(to right,#a78bfa 0 calc(7px + (100% - 14px) * var(--p)),rgba(255,255,255,.75) 0 calc(7px + (100% - 14px) * var(--b)),rgba(255,255,255,.45) 0)}
.vl .form-range::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vl .form-range::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vl .form-range::-webkit-slider-thumb{width:14px;height:14px;margin-top:-5px;background:#fff;transition:none}
.vl .form-range::-moz-range-thumb{width:14px;height:14px;border:0;background:#fff;transition:none}
.vl .form-range:focus::-webkit-slider-thumb{box-shadow:none}
.vl .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #7c3aed,0 0 0 5px #fff}
.vl .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #7c3aed,0 0 0 5px #fff}
.vl-cc-box{position:absolute;left:50%;bottom:96px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;padding:.3em .75em;
  background:rgba(18,13,36,.86);font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;white-space:pre-line;pointer-events:none}
.vl[data-idle] .vl-cc-box{bottom:18px}
/* 1. course */
.vl-course{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(0,1fr);gap:20px;align-items:start}
.vl-kicker{font-size:.78rem;letter-spacing:.06em;color:var(--vl-deep)}
.vl-step{--bs-btn-border-color:#cfc6e8;--bs-btn-hover-border-color:var(--vl-deep);--bs-btn-hover-color:var(--vl-deep);--bs-btn-hover-bg:#fff;
  --bs-btn-active-bg:#f6f3fd;--bs-btn-active-border-color:var(--vl-deep);--bs-btn-font-size:.88rem;--bs-btn-font-weight:600;--bs-btn-focus-box-shadow:none;height:40px}
.vl-step:focus-visible{outline:2px solid var(--vl-deep);outline-offset:2px}
.vl-step svg{width:18px;height:18px}
.vl-list{--bs-card-border-color:var(--vl-line);--bs-card-border-radius:18px}
.vl-list .list-group{--bs-list-group-border-width:0;--bs-list-group-action-color:#1e1b2e;--bs-list-group-action-hover-color:#1e1b2e;
  --bs-list-group-action-hover-bg:#f6f3fd;--bs-list-group-action-active-bg:#ede7fb}
.vl-item{--lp:0;display:grid !important;grid-template-columns:auto 1fr auto;align-items:center;gap:4px 12px;border-radius:12px !important}
.vl-item:focus-visible{outline:2px solid var(--vl-deep);outline-offset:-2px;z-index:2}
.vl-item[aria-current="true"]{background:#ede7fb}
.vl-num{width:30px;height:30px;border:1.5px solid #8b80a8;font-size:.8rem}
.vl-num svg{display:none;width:16px;height:16px}
.vl-item[aria-current="true"] .vl-num{border-color:var(--vl-deep);background:var(--vl-deep);color:#fff}
.vl-item[data-done] .vl-num{border-color:#15803d;background:#15803d;color:#fff}
.vl-item[data-done] .vl-num b{display:none}
.vl-item[data-done] .vl-num svg{display:block}
.vl-name{font-size:.92rem;line-height:1.3}
.vl-name small{font-size:.76rem;color:var(--vl-muted)}
.vl-len{font-size:.8rem;color:var(--vl-muted);font-variant-numeric:tabular-nums}
.vl-prog{grid-column:2 / -1;height:3px;border-radius:99px;background:linear-gradient(to right,var(--vl-deep) calc(var(--lp) * 100%),#ddd5ef 0)}
/* 2. thumbnail strip */
.vl-strip{scroll-snap-type:x mandatory;scrollbar-width:thin}
.vl-strip li{flex:1 0 200px;scroll-snap-align:start}
.vl-thumb{--lp:0}
.vl-thumb .vl-img{background:#120d24}
.vl-thumb img{transition:transform .3s;object-fit:cover}
.vl-thumb:hover img{transform:scale(1.04)}
.vl-thumb .vl-len{right:6px;bottom:8px;padding:1px 6px;background:rgba(18,13,36,.85);color:#fff;font-size:.74rem}
.vl-thumb .vl-img::after{content:"";position:absolute;inset:auto 0 0;height:4px;background:linear-gradient(to right,#a78bfa calc(var(--lp) * 100%),transparent 0)}
.vl-thumb .vl-name{font-size:.88rem}
.vl-thumb:focus-visible{outline:none}
.vl-thumb:focus-visible .vl-img{outline:2px solid var(--vl-deep);outline-offset:2px}
.vl-thumb[aria-current="true"] .vl-img{box-shadow:0 0 0 3px var(--vl-accent)}
.vl-thumb[aria-current="true"] .vl-name{color:var(--vl-deep)}
@media (max-width:820px){.vl-course{grid-template-columns:1fr}}
@media (max-width:560px){.vl-hide-sm{display:none !important}.vl-cc-box{bottom:92px}.vl-course h3.fs-5{font-size:1.1rem !important}.vl-step span{display:none}.vl-strip li{flex:0 0 160px}}
@media (prefers-reduced-motion:reduce){.vl-bar,.vl-thumb img{transition:none}.vl-thumb:hover img{transform: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) };
}

// Playlists. Each [data-item] button carries data-src, data-poster, data-captions, data-title and
// data-duration; selecting one loads it into the player. The current item has aria-current, each item
// keeps its own watched share in --lp, and the next item starts when one ends.
document.querySelectorAll('[data-vp="pl"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const items = [...root.querySelectorAll('[data-item]')];
  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 });
    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-[#5f5873] uppercase">Course player with a lesson list</span>
<div class="group/vp grid grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)] items-start gap-5 [--b:0] [--p:0] focus:outline-none max-[820px]:grid-cols-1" data-vp="pl" data-title="Fractal Tide" data-autohide role="group" aria-label="Generative Motion course player" tabindex="-1">
  <div>
    <div class="relative isolate overflow-hidden rounded-[18px] bg-[#120d24] text-white shadow-[0_18px_40px_rgba(60,30,120,.16)] 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-24 left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(18,13,36,.86)] px-[.75em] py-[.3em] text-center text-[clamp(.82rem,2.3vw,1.05rem)] leading-[1.4] whitespace-pre-line group-data-idle/vp:bottom-[18px] max-[560px]:bottom-[92px] max-[560px]:group-data-idle/vp:bottom-[18px]" data-cc-box hidden></p>
      <div class="absolute inset-x-0 bottom-0 z-[2] hidden flex-wrap items-center gap-0.5 bg-[linear-gradient(to_top,rgba(18,13,36,.92),rgba(18,13,36,.55)_60%,transparent)] px-3 pt-[30px] pb-2 transition-opacity duration-250 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
        <input type="range" class="vl-range mx-1 mb-1 flex-[1_0_100%]" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-play aria-label="Play Fractal Tide"><svg class="block size-[22px] 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-[22px] 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>
        <button type="button" class="group/m grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 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>
        <span class="mr-auto px-1.5 text-[.82rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
        <button type="button" class="relative grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white aria-pressed:after:absolute aria-pressed:after:inset-x-[9px] aria-pressed:after:bottom-1 aria-pressed:after:h-0.5 aria-pressed:after:rounded-sm aria-pressed:after:bg-[#c4b5fd]" 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-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 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 class="mt-4 flex items-center gap-3">
      <div class="min-w-0 flex-1"><p class="text-[.78rem] font-bold tracking-[.06em] text-[#6d28d9] uppercase" data-now-count data-label="Lesson {n} of {total}">Lesson 1 of 4</p><h3 class="mt-0.5 text-[1.3rem] font-bold tracking-[-.01em] max-[560px]:text-[1.1rem]" data-now-title>Fractal Tide</h3></div>
      <button type="button" class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full border border-[#cfc6e8] bg-white px-3.5 text-[.88rem] font-semibold hover:border-[#6d28d9] hover:text-[#6d28d9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#6d28d9] aria-disabled:cursor-default aria-disabled:border-[#cfc6e8] aria-disabled:text-inherit aria-disabled:opacity-45 max-[560px]:px-2.5" data-prev aria-label="Previous lesson" aria-disabled="true"><svg class="size-[18px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M19 6.2v11.6a1 1 0 0 1-1.53.85L9.4 13.1a1.3 1.3 0 0 1 0-2.2l8.07-5.55A1 1 0 0 1 19 6.2z"/></svg><span class="max-[560px]:hidden">Previous</span></button>
      <button type="button" class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full border border-[#cfc6e8] bg-white px-3.5 text-[.88rem] font-semibold hover:border-[#6d28d9] hover:text-[#6d28d9] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#6d28d9] aria-disabled:cursor-default aria-disabled:border-[#cfc6e8] aria-disabled:text-inherit aria-disabled:opacity-45 max-[560px]:px-2.5" data-next aria-label="Next lesson"><span class="max-[560px]:hidden">Next</span><svg class="size-[18px]" 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>
    </div>
  </div>
  <section class="overflow-hidden rounded-[18px] border border-[#e4def3] bg-white" aria-labelledby="vl-course-title">
    <header class="border-b border-[#e4def3] px-[18px] py-4"><h3 class="text-base font-bold" id="vl-course-title">Generative Motion, Module 1</h3><p class="mt-[3px] text-[.82rem] text-[#5f5873]">4 lessons · 1 min 2 s · Patterns from simple rules</p></header>
    <ol class="p-1.5">
      <li><button type="button" class="group/li relative grid w-full cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1 rounded-xl px-3 py-2.5 text-left [--lp:0] hover:bg-[#f6f3fd] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#6d28d9] aria-[current=true]:bg-[#ede7fb]" data-item 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">
        <span class="grid size-[30px] place-items-center rounded-full border-[1.5px] border-[#8b80a8] text-[.8rem] font-bold group-aria-[current=true]/li:border-[#6d28d9] group-aria-[current=true]/li:bg-[#6d28d9] group-aria-[current=true]/li:text-white group-data-done/li:border-[#15803d] group-data-done/li:bg-[#15803d] group-data-done/li:text-white" aria-hidden="true"><b class="group-data-done/li:hidden">1</b><svg class="hidden size-4 group-data-done/li: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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="text-[.92rem] leading-[1.3] font-semibold">Fractal Tide<small class="block text-[.76rem] font-medium text-[#5f5873]">Zooming into the Mandelbrot set</small></span>
        <span class="text-[.8rem] text-[#5f5873] tabular-nums"><span class="sr-only">Length </span>0:16</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span>
        <i class="col-[2/-1] h-[3px] rounded-full bg-[linear-gradient(to_right,#6d28d9_calc(var(--lp)*100%),#ddd5ef_0)]" aria-hidden="true"></i></button></li>
      <li><button type="button" class="group/li relative grid w-full cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1 rounded-xl px-3 py-2.5 text-left [--lp:0] hover:bg-[#f6f3fd] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#6d28d9] aria-[current=true]:bg-[#ede7fb]" data-item 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">
        <span class="grid size-[30px] place-items-center rounded-full border-[1.5px] border-[#8b80a8] text-[.8rem] font-bold group-aria-[current=true]/li:border-[#6d28d9] group-aria-[current=true]/li:bg-[#6d28d9] group-aria-[current=true]/li:text-white group-data-done/li:border-[#15803d] group-data-done/li:bg-[#15803d] group-data-done/li:text-white" aria-hidden="true"><b class="group-data-done/li:hidden">2</b><svg class="hidden size-4 group-data-done/li: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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="text-[.92rem] leading-[1.3] font-semibold">Rule 30 Loom<small class="block text-[.76rem] font-medium text-[#5f5873]">One row of cells, one rule</small></span>
        <span class="text-[.8rem] text-[#5f5873] tabular-nums"><span class="sr-only">Length </span>0:20</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span>
        <i class="col-[2/-1] h-[3px] rounded-full bg-[linear-gradient(to_right,#6d28d9_calc(var(--lp)*100%),#ddd5ef_0)]" aria-hidden="true"></i></button></li>
      <li><button type="button" class="group/li relative grid w-full cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1 rounded-xl px-3 py-2.5 text-left [--lp:0] hover:bg-[#f6f3fd] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#6d28d9] aria-[current=true]:bg-[#ede7fb]" data-item 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">
        <span class="grid size-[30px] place-items-center rounded-full border-[1.5px] border-[#8b80a8] text-[.8rem] font-bold group-aria-[current=true]/li:border-[#6d28d9] group-aria-[current=true]/li:bg-[#6d28d9] group-aria-[current=true]/li:text-white group-data-done/li:border-[#15803d] group-data-done/li:bg-[#15803d] group-data-done/li:text-white" aria-hidden="true"><b class="group-data-done/li:hidden">3</b><svg class="hidden size-4 group-data-done/li: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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="text-[.92rem] leading-[1.3] font-semibold">Life in Colour<small class="block text-[.76rem] font-medium text-[#5f5873]">Conway's Game of Life</small></span>
        <span class="text-[.8rem] text-[#5f5873] tabular-nums"><span class="sr-only">Length </span>0:14</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span>
        <i class="col-[2/-1] h-[3px] rounded-full bg-[linear-gradient(to_right,#6d28d9_calc(var(--lp)*100%),#ddd5ef_0)]" aria-hidden="true"></i></button></li>
      <li><button type="button" class="group/li relative grid w-full cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1 rounded-xl px-3 py-2.5 text-left [--lp:0] hover:bg-[#f6f3fd] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#6d28d9] aria-[current=true]:bg-[#ede7fb]" data-item 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">
        <span class="grid size-[30px] place-items-center rounded-full border-[1.5px] border-[#8b80a8] text-[.8rem] font-bold group-aria-[current=true]/li:border-[#6d28d9] group-aria-[current=true]/li:bg-[#6d28d9] group-aria-[current=true]/li:text-white group-data-done/li:border-[#15803d] group-data-done/li:bg-[#15803d] group-data-done/li:text-white" aria-hidden="true"><b class="group-data-done/li:hidden">4</b><svg class="hidden size-4 group-data-done/li: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="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
        <span class="text-[.92rem] leading-[1.3] font-semibold">Slow Gradients<small class="block text-[.76rem] font-medium text-[#5f5873]">Colour fields that drift</small></span>
        <span class="text-[.8rem] text-[#5f5873] tabular-nums"><span class="sr-only">Length </span>0:12</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span>
        <i class="col-[2/-1] h-[3px] rounded-full bg-[linear-gradient(to_right,#6d28d9_calc(var(--lp)*100%),#ddd5ef_0)]" aria-hidden="true"></i></button></li>
    </ol>
  </section>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5f5873] uppercase">Playlist with a thumbnail strip</span>
<div class="group/vp [--b:0] [--p:0] focus:outline-none" data-vp="pl" data-title="Life in Colour" data-autohide role="group" aria-label="Northlight Studies playlist" tabindex="-1">
  <div class="relative isolate overflow-hidden rounded-[18px] bg-[#120d24] text-white shadow-[0_18px_40px_rgba(60,30,120,.16)] 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-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-24 left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(18,13,36,.86)] px-[.75em] py-[.3em] text-center text-[clamp(.82rem,2.3vw,1.05rem)] leading-[1.4] whitespace-pre-line group-data-idle/vp:bottom-[18px] max-[560px]:bottom-[92px] max-[560px]:group-data-idle/vp:bottom-[18px]" data-cc-box hidden></p>
    <div class="absolute inset-x-0 bottom-0 z-[2] hidden flex-wrap items-center gap-0.5 bg-[linear-gradient(to_top,rgba(18,13,36,.92),rgba(18,13,36,.55)_60%,transparent)] px-3 pt-[30px] pb-2 transition-opacity duration-250 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
      <input type="range" class="vl-range mx-1 mb-1 flex-[1_0_100%]" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent" data-prev aria-label="Previous video"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="5" y="5" width="2.6" height="14" rx="1"/><path d="M19 6.2v11.6a1 1 0 0 1-1.53.85L9.4 13.1a1.3 1.3 0 0 1 0-2.2l8.07-5.55A1 1 0 0 1 19 6.2z"/></svg></button>
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 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-[22px] 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-[22px] 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>
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent" data-next aria-label="Next video"><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>
      <span class="mr-auto px-1.5 text-[.82rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:14</span></span>
      <button type="button" class="group/m grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" 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="relative grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white aria-pressed:after:absolute aria-pressed:after:inset-x-[9px] aria-pressed:after:bottom-1 aria-pressed:after:h-0.5 aria-pressed:after:rounded-sm aria-pressed:after:bg-[#c4b5fd]" 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-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 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 class="mx-0.5 mt-4 mb-2.5 flex items-baseline justify-between"><h3 class="text-[1.05rem] font-bold" data-now-title>Life in Colour</h3><p class="text-[.82rem] text-[#5f5873]" data-now-count data-label="Video {n} of {total}">Video 1 of 4</p></div>
  <ol class="relative flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pt-1 pb-2.5 [scrollbar-width:thin]" aria-label="Playlist">
    <li class="flex-[1_0_200px] snap-start max-[560px]:flex-[0_0_160px]"><button type="button" class="group/t relative block w-full cursor-pointer text-left [--lp:0] focus-visible:outline-none" data-item 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" aria-current="true">
      <span class="relative block overflow-hidden rounded-xl bg-[#120d24] group-focus-visible/t:outline-2 group-focus-visible/t:outline-offset-2 group-focus-visible/t:outline-[#6d28d9] group-aria-[current=true]/t:shadow-[0_0_0_3px_#7c3aed] after:absolute after:inset-x-0 after:bottom-0 after:h-1 after:bg-[linear-gradient(to_right,#a78bfa_calc(var(--lp)*100%),transparent_0)]"><img class="block aspect-video h-auto w-full object-cover transition-transform duration-300 group-hover/t:scale-104 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute right-1.5 bottom-2 rounded-md bg-[rgba(18,13,36,.85)] px-1.5 py-px text-[.74rem] text-white"><span class="sr-only">Length </span>0:14</span></span><span class="mt-2 block text-[.88rem] font-semibold group-aria-[current=true]/t:text-[#6d28d9]">Life in Colour</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span></button></li>
    <li class="flex-[1_0_200px] snap-start max-[560px]:flex-[0_0_160px]"><button type="button" class="group/t relative block w-full cursor-pointer text-left [--lp:0] focus-visible:outline-none" data-item 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">
      <span class="relative block overflow-hidden rounded-xl bg-[#120d24] group-focus-visible/t:outline-2 group-focus-visible/t:outline-offset-2 group-focus-visible/t:outline-[#6d28d9] group-aria-[current=true]/t:shadow-[0_0_0_3px_#7c3aed] after:absolute after:inset-x-0 after:bottom-0 after:h-1 after:bg-[linear-gradient(to_right,#a78bfa_calc(var(--lp)*100%),transparent_0)]"><img class="block aspect-video h-auto w-full object-cover transition-transform duration-300 group-hover/t:scale-104 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute right-1.5 bottom-2 rounded-md bg-[rgba(18,13,36,.85)] px-1.5 py-px text-[.74rem] text-white"><span class="sr-only">Length </span>0:16</span></span><span class="mt-2 block text-[.88rem] font-semibold group-aria-[current=true]/t:text-[#6d28d9]">Fractal Tide</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span></button></li>
    <li class="flex-[1_0_200px] snap-start max-[560px]:flex-[0_0_160px]"><button type="button" class="group/t relative block w-full cursor-pointer text-left [--lp:0] focus-visible:outline-none" data-item 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">
      <span class="relative block overflow-hidden rounded-xl bg-[#120d24] group-focus-visible/t:outline-2 group-focus-visible/t:outline-offset-2 group-focus-visible/t:outline-[#6d28d9] group-aria-[current=true]/t:shadow-[0_0_0_3px_#7c3aed] after:absolute after:inset-x-0 after:bottom-0 after:h-1 after:bg-[linear-gradient(to_right,#a78bfa_calc(var(--lp)*100%),transparent_0)]"><img class="block aspect-video h-auto w-full object-cover transition-transform duration-300 group-hover/t:scale-104 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute right-1.5 bottom-2 rounded-md bg-[rgba(18,13,36,.85)] px-1.5 py-px text-[.74rem] text-white"><span class="sr-only">Length </span>0:12</span></span><span class="mt-2 block text-[.88rem] font-semibold group-aria-[current=true]/t:text-[#6d28d9]">Slow Gradients</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span></button></li>
    <li class="flex-[1_0_200px] snap-start max-[560px]:flex-[0_0_160px]"><button type="button" class="group/t relative block w-full cursor-pointer text-left [--lp:0] focus-visible:outline-none" data-item 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">
      <span class="relative block overflow-hidden rounded-xl bg-[#120d24] group-focus-visible/t:outline-2 group-focus-visible/t:outline-offset-2 group-focus-visible/t:outline-[#6d28d9] group-aria-[current=true]/t:shadow-[0_0_0_3px_#7c3aed] after:absolute after:inset-x-0 after:bottom-0 after:h-1 after:bg-[linear-gradient(to_right,#a78bfa_calc(var(--lp)*100%),transparent_0)]"><img class="block aspect-video h-auto w-full object-cover transition-transform duration-300 group-hover/t:scale-104 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute right-1.5 bottom-2 rounded-md bg-[rgba(18,13,36,.85)] px-1.5 py-px text-[.74rem] text-white"><span class="sr-only">Length </span>0:20</span></span><span class="mt-2 block text-[.88rem] font-semibold group-aria-[current=true]/t:text-[#6d28d9]">Rule 30 Loom</span><span class="sr-only">, </span><span class="sr-only" data-state>Not started</span></button></li>
  </ol>
</div></div>
CSS
/* The seek slider needs vendor pseudo-elements, so it gets one small utility. --p and --b are set by the script. */
@utility vl-range {
  -webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 14px; background: none; cursor: pointer;
  --fill: linear-gradient(to right, #a78bfa 0 calc(7px + (100% - 14px) * var(--p)), rgb(255 255 255 / .75) 0 calc(7px + (100% - 14px) * var(--b)), rgb(255 255 255 / .45) 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: #fff; }
  &::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #fff; }
  &:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px #7c3aed, 0 0 0 5px #fff; }
  &:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #7c3aed, 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) };
}

// Playlists. Each [data-item] button carries data-src, data-poster, data-captions, data-title and
// data-duration; selecting one loads it into the player. The current item has aria-current, each item
// keeps its own watched share in --lp, and the next item starts when one ends.
document.querySelectorAll('[data-vp="pl"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const items = [...root.querySelectorAll('[data-item]')];
  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 });
    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 players that hold more than one video. The course player puts the video next to a lesson list for a four-lesson module: each lesson row shows its number, title, a short description, its duration and a progress bar for how much of it has been watched, and the number turns into a green tick when a lesson is finished. Under the video a heading shows Lesson 2 of 4 and the lesson title, with Previous and Next buttons that switch lessons and keep playing if the video was playing. The playlist with a thumbnail strip has previous and next buttons in its control bar and a scrolling row of thumbnails with duration badges and a watched line, snapped with scroll-snap. In both, every list item is a button that carries the video’s source, poster, captions file, title and duration in data attributes. Selecting one loads it into the single video element, swaps the captions track, updates the title and marks the item with aria-current. When a video ends, the next one starts. Watched progress is kept per item while the page is open.

What’s included

  • Lesson list with durations, per-lesson progress and completed ticks
  • Previous and next buttons with aria-disabled at either end
  • Thumbnail strip with scroll-snap, duration badges and watched lines
  • One video element; items swap source, poster and captions
  • Next item starts when the current one ends

Accessibility

Each lesson and thumbnail is a button whose name includes its title, length and a status of Not started, In progress or Completed, and the current one has aria-current. Previous and next use aria-disabled rather than disabled, so a focused button keeps focus at the end of the list. All controls show a focus outline and the thumbnail zoom stops under prefers-reduced-motion.

Customise it

Change --vl-accent and --vl-deep on .vl for highlights and progress bars, and the flex-basis of .vl-strip li for thumbnail width. Bootstrap uses .list-group and .btn variables; in Tailwind edit the colour values in the aria-[current=true] and group-data-done classes.