(() => {
// 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.