(() => {
// 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) };
}
// Chapters and transcript. Chapter buttons are built from <track kind="chapters"> by cloning
// [data-chapter-template]; transcript lines are buttons with data-at="seconds". Both seek on click
// and carry aria-current while their part of the video plays.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('[data-vp="ch"]').forEach(root => {
const player = vpPlayer(root), video = player.video;
const list = root.querySelector('[data-chapters]'), tpl = root.querySelector('[data-chapter-template]');
const ticks = root.querySelector('[data-ticks]'), now = root.querySelector('[data-chapter-now]');
const box = root.querySelector('[data-transcript]'), lines = box ? [...box.querySelectorAll('[data-at]')] : [];
const chapterTrack = video.querySelector('track[kind="chapters"]');
let chapters = [], buttons = [], current = null;
const seekTo = t => { player.jump(t); player.start(); };
const build = () => {
const cues = chapterTrack && chapterTrack.track.cues;
if (!cues || !cues.length || chapters.length) return;
chapters = [...cues].map(c => ({ start: c.startTime, end: c.endTime, title: c.text }));
const d = video.duration || chapters[chapters.length - 1].end;
if (ticks) ticks.replaceChildren(...chapters.slice(1).map(c => {
const i = document.createElement('i');
i.style.left = (c.start / d) * 100 + '%';
return i;
}));
if (list && tpl) {
list.replaceChildren(...chapters.map(c => {
const li = tpl.content.firstElementChild.cloneNode(true), b = li.querySelector('button');
li.querySelector('[data-chapter-time]').textContent = vpFmt(c.start);
li.querySelector('[data-chapter-title]').textContent = c.title;
b.addEventListener('click', () => seekTo(c.start));
return li;
}));
buttons = [...list.querySelectorAll('button')];
}
update(video.currentTime);
};
const update = t => {
const i = chapters.findLastIndex(c => c.start <= t + 0.05);
buttons.forEach((b, n) => {
const c = chapters[n];
b.style.setProperty('--cp', Math.min(Math.max((t - c.start) / (c.end - c.start), 0), 1));
if (n === i) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
});
if (now) now.textContent = i >= 0 ? chapters[i].title : '';
const line = lines.filter(l => +l.dataset.at <= t + 0.05).pop() || null;
if (line === current) return;
if (current) current.removeAttribute('aria-current');
current = line;
if (!line) return;
line.setAttribute('aria-current', 'true');
// keep the current line in view inside the transcript box, without scrolling the page
const top = line.offsetTop, bottom = top + line.offsetHeight;
if (top < box.scrollTop || bottom > box.scrollTop + box.clientHeight)
box.scrollTo({ top: top - box.clientHeight / 3, behavior: reduceMotion.matches ? 'auto' : 'smooth' });
};
player.onTime(update);
lines.forEach(l => l.addEventListener('click', () => seekTo(+l.dataset.at)));
if (chapterTrack) {
chapterTrack.track.mode = 'hidden'; // loads the cues without showing them
chapterTrack.addEventListener('load', build);
if (chapterTrack.readyState === 2) build();
}
update(0);
});
})();
About this set
Two players for long-form video where people want to skip to the part they need. The chapters player reads a WebVTT file loaded through a track element with kind set to chapters. The script turns each cue into a chapter card under the video with its start time, title and a small progress bar, draws a gap on the seek slider at every chapter boundary, and shows the current chapter’s name in the control bar. Clicking a card seeks to that chapter and starts playback, and the current card carries aria-current. The interactive transcript player places the video beside a scrolling transcript panel; on narrow screens the panel moves below the video. Every transcript line is a button with its timestamp and a data-at attribute in seconds, so the transcript is plain HTML that search engines and screen readers can read without the video. Clicking a line seeks there, the line that matches the playback position is highlighted with aria-current, and the panel scrolls itself to keep that line in view without moving the page. Lines include sound descriptions in brackets, which makes the transcript a text alternative for the whole clip.
What’s included
Chapter cards and seek bar gaps built from a WebVTT chapters track
Current chapter name in the control bar and aria-current on its card
Transcript lines are buttons that seek to their timestamp
Current line highlighted and kept in view inside the panel only
Transcript is plain HTML, readable without JavaScript
Accessibility
Chapter cards and transcript lines are buttons with their time and text as the accessible name, and the current one has aria-current. The seek slider announces text such as 0:05 of 0:20. Auto-scrolling of the transcript is instant instead of smooth under prefers-reduced-motion, and every control shows a visible focus outline.
Customise it
Change --vc-accent and --vc-deep on .vc for the seek fill, chapter progress and highlights, and the max-height of .vc-lines for the transcript height. Bootstrap uses .list-group variables for the transcript; in Tailwind edit the colour values in the aria-[current=true] classes.