95Video player
One themed control surface over three sources that are not alike: a self-hosted file, a Mux HLS stream, and a YouTube embed. The differences are documented rather than papered over.
Use it for
- Lesson video, wherever it is hosted
- Anywhere chapters matter as much as the video
Not for
- Background or decorative video — that is a poster with no controls
- Replacing the native controls when you have nothing to add: the browser's own player is good, and a worse copy of it is a regression
Self-hosted, with chapters
Chapters live BELOW the player as a real list, not hovering over it. A chapter list is content: it is how a reader decides whether to watch at all, it should be readable without playing, and it should be in the page for search. Hiding it inside the video is the common mistake.
markup
<div class="ns-vplayer" data-ns-video data-state="paused">
<div class="ns-vplayer__stage">
<video preload="metadata" playsinline></video>
<button class="ns-vplayer__big" type="button" aria-label="Play"><i class="ph ph-play" aria-hidden="true"></i></button>
</div>
<div class="ns-vplayer__bar">
<button class="ns-vplayer__btn" type="button" data-ns-video-play aria-label="Play"><i class="ph ph-play" aria-hidden="true"></i></button>
<span class="ns-vplayer__time" data-ns-video-current>0:00</span>
<input class="ns-vplayer__seek" type="range" min="0" max="100" value="0" aria-label="Seek">
<span class="ns-vplayer__time" data-ns-video-duration>0:00</span>
<details class="ns-vplayer__menu">
<summary class="ns-vplayer__btn" aria-label="Settings"><i class="ph ph-gear-six" aria-hidden="true"></i></summary>
<div class="ns-vplayer__panel" role="group" aria-label="Playback speed">
<button class="ns-vplayer__opt" type="button" role="radio" aria-checked="false" data-rate="0.75">0.75×</button>
<button class="ns-vplayer__opt" type="button" role="radio" aria-checked="true" data-rate="1">Normal</button>
<button class="ns-vplayer__opt" type="button" role="radio" aria-checked="false" data-rate="1.5">1.5×</button>
<button class="ns-vplayer__opt" type="button" role="radio" aria-checked="false" data-rate="2">2×</button>
</div>
</details>
</div>
<ol class="ns-vchapters">
<li class="ns-vchapters__item" data-start="0" aria-current="true"><button class="ns-vchapters__btn" type="button"><span class="ns-vchapters__time">0:00</span><span class="ns-vchapters__title">What an org actually is</span></button></li>
<li class="ns-vchapters__item" data-start="95"><button class="ns-vchapters__btn" type="button"><span class="ns-vchapters__time">1:35</span><span class="ns-vchapters__title">Objects, fields and records</span></button></li>
<li class="ns-vchapters__item" data-start="240"><button class="ns-vchapters__btn" type="button"><span class="ns-vchapters__time">4:00</span><span class="ns-vchapters__title">Where metadata lives</span></button></li>
</ol>
</div>Mux and YouTube
Same markup, one attribute different. Mux serves HLS: Safari plays it natively, and elsewhere the player uses window.Hls if you have loaded it — this system does not bundle hls.js, because it is 40KB+ and most pages never play a video. If neither is available the player says so rather than showing a dead frame.
markup
<!-- Mux -->
<div class="ns-vplayer" data-ns-video data-mux="PLAYBACK_ID" data-state="paused">
<div class="ns-vplayer__stage"><video preload="metadata" playsinline></video></div>
<div class="ns-vplayer__bar">
<button class="ns-vplayer__btn" type="button" data-ns-video-play aria-label="Play"><i class="ph ph-play" aria-hidden="true"></i></button>
<span class="ns-vplayer__time" data-ns-video-current>0:00</span>
<input class="ns-vplayer__seek" type="range" min="0" max="100" value="0" aria-label="Seek">
<span class="ns-vplayer__time" data-ns-video-duration>0:00</span>
</div>
</div>
<!-- YouTube: the iframe is injected, and these controls drive it via the IFrame API -->
<div class="ns-vplayer" data-ns-video data-youtube="VIDEO_ID" data-title="Lesson 1" data-state="paused">
<div class="ns-vplayer__stage"></div>
<div class="ns-vplayer__bar">
<button class="ns-vplayer__btn" type="button" data-ns-video-play aria-label="Play"><i class="ph ph-play" aria-hidden="true"></i></button>
<span class="ns-vplayer__time" data-ns-video-current>0:00</span>
<input class="ns-vplayer__seek" type="range" min="0" max="100" value="0" aria-label="Seek">
<span class="ns-vplayer__time" data-ns-video-duration>0:00</span>
</div>
</div>Chapters
A contents page for a video, not a log file. The old list set a mono timestamp and a title on one baseline in a bordered row, fifteen times — a table of numbers, when the reader is scanning TITLES and using time only to decide whether to commit. So the title leads, time is metadata under it, and the row's leading edge carries watched / playing / ahead — the same device the lesson rail uses.
markup
<ol class="ns-vchapters ns-vchapters--notes" style="max-inline-size:26rem;inline-size:100%;border:1px solid var(--color-border);border-radius:var(--radius-card)">
<li class="ns-vchapters__item" data-state="done">
<button type="button" class="ns-vchapters__btn">
<span class="ns-vchapters__title">What a trigger actually receives</span>
<span class="ns-vchapters__time">00:00</span>
<span class="ns-vchapters__meta">4 min · watched</span>
</button>
</li>
<li class="ns-vchapters__item" aria-current="true">
<button type="button" class="ns-vchapters__btn">
<span class="ns-vchapters__title">Why 200 records breaks it</span>
<span class="ns-vchapters__time">04:12</span>
<span class="ns-vchapters__meta">6 min · playing</span>
</button>
</li>
<li class="ns-vchapters__item">
<button type="button" class="ns-vchapters__btn">
<span class="ns-vchapters__title">The Map, and one query</span>
<span class="ns-vchapters__time">10:40</span>
<span class="ns-vchapters__meta">7 min</span>
</button>
</li>
<li class="ns-vchapters__item">
<button type="button" class="ns-vchapters__btn">
<span class="ns-vchapters__title">Testing the bulk case</span>
<span class="ns-vchapters__time">17:55</span>
<span class="ns-vchapters__meta">3 min</span>
</button>
</li>
</ol>Inline chapters
--inline for a player with no rail: one line each, time first, because in this shape the reader IS seeking rather than choosing.
markup
<ol class="ns-vchapters ns-vchapters--inline" style="max-inline-size:26rem;inline-size:100%;border:1px solid var(--color-border);border-radius:var(--radius-card)">
<li class="ns-vchapters__item" data-state="done"><button type="button" class="ns-vchapters__btn"><span class="ns-vchapters__time">00:00</span><span class="ns-vchapters__title">What a trigger receives</span></button></li>
<li class="ns-vchapters__item" aria-current="true"><button type="button" class="ns-vchapters__btn"><span class="ns-vchapters__time">04:12</span><span class="ns-vchapters__title">Why 200 records breaks it</span></button></li>
<li class="ns-vchapters__item"><button type="button" class="ns-vchapters__btn"><span class="ns-vchapters__time">10:40</span><span class="ns-vchapters__title">The Map, and one query</span></button></li>
</ol>Accessibility contract
- The scrubber is a real <input type="range">: keyboard seeking, screen-reader announcement and touch behaviour all come free, and every div-based scrubber reimplements them worse
- Progressive: the markup contains a real <video> and the chapter list is server-rendered text, so with JS off the video plays in the browser's own controls and the chapters are still readable
- The playing chapter is aria-current — the highlighted row and the announced row are one thing
- YouTube's player is a cross-origin iframe and cannot be restyled. These controls DRIVE it via the IFrame API, which is why the chrome sits outside the frame: an overlay would sit on top of YouTube's own controls and fight them