/* Range inputs need vendor pseudo-elements, so they get one small utility. --f is the filled share
(defaults to --p, the played share) and --fb the buffered share; both are set by the script. */
@utility vd-range {
-webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 16px; margin: 0; background: none; cursor: pointer;
--f: var(--vd-f, var(--p)); --fb: var(--vd-fb, var(--b));
--fill: linear-gradient(to right, #e11d48 0 calc(7px + (100% - 14px) * var(--f)), rgb(255 255 255 / .6) 0 calc(7px + (100% - 14px) * var(--fb)), rgb(255 255 255 / .35) 0);
&:focus-visible { outline: none; }
&::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--fill); }
&::-moz-range-track { height: 4px; border-radius: 99px; background: var(--fill); }
&::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 0; border-radius: 50%; background: #e11d48; }
&::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #e11d48; }
&:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px #000, 0 0 0 5px #fff; }
&:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #000, 0 0 0 5px #fff; }
}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
const $ = s => root.querySelector(s);
const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
const stage = $('[data-stage]') || root; // the element that goes full screen
const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
let raf = 0, idle = 0, track = null, pointer = 'mouse';
video.controls = false;
root.setAttribute('data-ready', '');
const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
const sync = () => {
const t = video.currentTime, d = length();
root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
if (cur) cur.textContent = vpFmt(t);
if (seek) {
seek.value = t;
const text = vpFmt(t) + ' of ' + vpFmt(d);
if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
}
listeners.forEach(fn => fn(t, d));
};
const buffered = () => {
const b = video.buffered, d = length();
let end = 0;
for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
};
const tick = () => { sync(); raf = requestAnimationFrame(tick); };
const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
const start = () => {
// one player at a time; muted background loops (data-bg) keep running
document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
return video.play().catch(() => {});
};
const toggle = () => (video.paused || video.ended ? start() : video.pause());
const name = () => root.dataset.title || 'video';
const state = () => {
const playing = !video.paused;
root.toggleAttribute('data-playing', playing);
plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
// if the focused play button was just hidden (a big centre button), hand focus to a visible one
const a = document.activeElement;
if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
};
// Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
// so the text sits above the control bar. Players without a box fall back to native rendering.
// A player without a captions button shows captions when the root has data-captions.
const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
const render = () => {
if (!ccBox) return;
const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
ccBox.textContent = text;
ccBox.hidden = !text;
};
const captions = () => {
track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
if (cc) cc.hidden = !track;
if (!track) return render();
track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
track.oncuechange = render;
render();
};
// Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
function wake() {
root.removeAttribute('data-idle');
clearTimeout(idle);
if (!root.hasAttribute('data-autohide') || video.paused) return;
idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
}
const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
const fullscreen = () => {
if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
};
const fsSync = () => {
const on = fsElement() === stage;
root.toggleAttribute('data-fullscreen', on);
if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
};
plays.forEach(b => b.addEventListener('click', toggle));
root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
video.addEventListener('click', () => {
// a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
toggle();
if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
});
video.addEventListener('play', () => { state(); tick(); wake(); });
video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
video.addEventListener('loadedmetadata', () => { meta(); captions(); });
video.addEventListener('progress', buffered);
video.addEventListener('seeked', () => { sync(); buffered(); });
video.addEventListener('volumechange', () => {
const v = video.muted ? 0 : video.volume;
root.style.setProperty('--v', v);
if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
if (mute) mute.setAttribute('aria-pressed', String(v === 0));
});
video.addEventListener('ratechange', () => {
if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
});
video.textTracks.addEventListener('addtrack', captions);
video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
if (seek) seek.addEventListener('input', () => jump(+seek.value));
if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
if (mute) mute.addEventListener('click', () => {
if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
else video.muted = true;
});
if (cc) cc.addEventListener('click', () => {
cc.setAttribute('aria-pressed', String(!ccOn()));
if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
render();
});
if (speed) speed.addEventListener('click', () => {
const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
video.playbackRate = video.defaultPlaybackRate = rate;
});
if (fs) {
fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
fs.addEventListener('click', fullscreen);
document.addEventListener('fullscreenchange', fsSync);
document.addEventListener('webkitfullscreenchange', fsSync);
}
if (pip) {
pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
pip.addEventListener('click', () => (document.pictureInPictureElement === video
? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
}
['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
root.addEventListener('pointerleave', () => {
if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
clearTimeout(idle);
idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
});
root.addEventListener('keydown', e => {
if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
wake();
if (k === 'k' || (k === ' ' && !control)) toggle();
else if (k === 'm') video.muted = !video.muted;
else if (k === 'f' && fs && !fs.hidden) fullscreen();
else if (k === 'c' && cc && !cc.hidden) cc.click();
else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
else return;
e.preventDefault();
});
// load() swaps the source, poster and captions file (playlists and lightboxes use it)
const load = ({ src, poster, captions: vtt, title, duration }) => {
video.pause();
video.querySelectorAll('track').forEach(t => t.remove());
if (title) root.dataset.title = title;
video.poster = poster || '';
video.src = src;
if (vtt) {
const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
t.addEventListener('load', captions);
video.append(t);
}
if (seek && duration) seek.max = duration;
if (dur && duration) dur.textContent = vpFmt(duration);
video.load();
state(); sync(); captions();
};
if (video.readyState > 0) meta();
captions(); state(); fsSync();
video.dispatchEvent(new Event('volumechange'));
return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}
// Dark streaming players. A player may carry an "up next" rail: each [data-item] button holds data-src,
// data-poster, data-captions, data-title and data-duration, plus data-watched (0 to 1) for the
// continue-watching bar it paints through --lp. The current tile has aria-current.
document.querySelectorAll('[data-vp="dk"]').forEach(root => {
const player = vpPlayer(root), video = player.video;
const items = [...root.querySelectorAll('[data-item]')];
if (!items.length) return;
const prev = root.querySelector('[data-prev]'), next = root.querySelector('[data-next]');
const title = root.querySelector('[data-now-title]'), count = root.querySelector('[data-now-count]');
let index = Math.max(0, items.findIndex(i => i.getAttribute('aria-current') === 'true'));
const mark = () => {
items.forEach((it, n) => { if (n === index) it.setAttribute('aria-current', 'true'); else it.removeAttribute('aria-current'); });
// aria-disabled rather than disabled, so a focused button keeps focus at either end of the list
if (prev) prev.setAttribute('aria-disabled', String(index === 0));
if (next) next.setAttribute('aria-disabled', String(index === items.length - 1));
if (title) title.textContent = items[index].dataset.title;
if (count) count.textContent = count.dataset.label.replace('{n}', index + 1).replace('{total}', items.length);
};
const progress = (it, share) => {
const watched = Math.max(+it.dataset.watched || 0, share);
it.dataset.watched = watched;
it.style.setProperty('--lp', watched);
const state = watched > 0.97 ? 'Completed' : watched > 0 ? 'In progress' : 'Not started';
const label = it.querySelector('[data-state]');
if (label && label.textContent !== state) label.textContent = state;
it.toggleAttribute('data-done', state === 'Completed');
};
const select = (n, play) => {
if (n < 0 || n >= items.length) return;
index = n;
const it = items[n];
player.load({ src: it.dataset.src, poster: it.dataset.poster, captions: it.dataset.captions, title: it.dataset.title, duration: +it.dataset.duration });
// continue watching: resume where the visitor left off unless the video was finished
const resume = +it.dataset.watched || 0;
if (resume > 0 && resume < 0.97) video.addEventListener('loadedmetadata', () => player.jump(resume * video.duration), { once: true });
mark();
if (play) player.start();
};
player.onTime((t, d) => { if (d) progress(items[index], t / d); });
video.addEventListener('ended', () => { progress(items[index], 1); select(index + 1, true); });
items.forEach((it, n) => it.addEventListener('click', () => select(n, true)));
if (prev) prev.addEventListener('click', () => select(index - 1, !video.paused));
if (next) next.addEventListener('click', () => select(index + 1, !video.paused));
mark();
});
})();
About this set
Two dark players in the style of a streaming app. The streaming player shows the series name and episode title at the top, a centre cluster with back 10 seconds, a large rose play button and forward 10 seconds, and a bottom bar with a seek slider, mute and volume, time, playback speed, captions, next episode and full screen; the overlays fade out together during playback. Under the player an up next rail lists four episodes as thumbnail tiles with their number, length and a continue-watching bar. Selecting a tile loads that episode and resumes from where the bar says the visitor left off, unless it was finished, and the next episode starts when one ends. The compact dark card puts a player with a centre play button into a card with a seek row, mute and full screen, a title, a description and detail chips, for related videos in a sidebar or a feed. Colours are chosen for dark interfaces: muted text stays above 7 to 1 on the page and slider rails stay visible against the video scrim. The Bootstrap version runs under data-bs-theme set to dark.
What’s included
Title overlay, centre controls and a full bottom bar
Up next rail with continue-watching bars and resume
Next episode button and automatic next episode
Compact dark card with captions always on
Bootstrap version under data-bs-theme dark
Accessibility
All controls are named buttons and sliders, the episode tiles carry their number, title, length and a watched status in their name, and the current tile has aria-current. Captions are on by default. Focus shows a white outline that stands out on the dark surfaces, and the overlay fades are removed under prefers-reduced-motion.
Customise it
Change --vd-accent, --vd-surface and --vd-muted on .vd for the rose accent, card surface and secondary text. Bootstrap restyles .btn and .form-range through custom properties under data-bs-theme dark; in Tailwind edit the #e11d48 values and the vd-range utility.