(() => {
// 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 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() === root) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
else if (root.requestFullscreen) root.requestFullscreen().catch(() => {});
else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen();
else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
};
const fsSync = () => {
const on = fsElement() === root;
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 = !(root.requestFullscreen || root.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) };
}
document.querySelectorAll('[data-vp="ov"]').forEach(vpPlayer);
})();
About this set
Three players that keep the picture clean. The big play button player shows a frosted round button in the middle of the poster and a slim bar with play, a thin seek line, time, captions, mute and full screen; once the video plays, the centre button disappears and focus moves to the bar’s play button if it was on the centre one, so keyboard users never lose their place. The corner pills player has a rounded frame with a white play button and time in a pill on the left, captions, mute and full screen in a pill on the right, and a seek line along the bottom edge; all of it fades away during playback. The square clip crops the video to a square card for social feeds, with tap to play, a seek line and mute at the top and captions always shown, plus a creator line under the card. Each player wraps a native video element with preload set to metadata, so nothing but the length and poster loads until someone presses play, and a single script with no dependencies connects the controls. Only one video plays at a time on the page.
What’s included
Big centred play button that hands focus to the bar's play button
Corner pill controls with a seek line along the bottom edge
Square social clip with tap to play and captions always on
Controls fade out during playback and return on movement or focus
Starting one video pauses any other on the page
Accessibility
Each player is a group named after its video, the play buttons change their label between Play and Pause, mute and captions use aria-pressed, and the seek slider reports the current and total time as text. Controls stay visible while they hold keyboard focus and show a white outline. The button hover scale and the control fades are removed under prefers-reduced-motion.
Customise it
Change --vm-accent on .vm for the seek fill, and the border-radius on .vm, .vm-corner and .vm-clip .vm for the frame shape. Bootstrap restyles .btn through --bs-btn-* variables; in Tailwind change the accent in the vm-range utility and the rounded-* classes.