LMS — Lesson Player Controls
A polished e-learning lesson player control bar with a draggable scrubber that shows buffered and played ranges, a hover time tooltip, volume slider with mute, a playback-speed menu, captions and quality toggles, fullscreen, and a next-lesson button. Full keyboard shortcuts drive play, seek, volume, mute, captions, fullscreen and skip. Built with semantic landmarks, ARIA slider roles and AA contrast, it stays usable from wide desktop down to narrow mobile screens.
MCP
Code
:root {
--brand: #5b5bd6;
--brand-d: #4444c2;
--brand-50: #eeeefc;
--accent: #13b981;
--amber: #f59e0b;
--ink: #1a1a2e;
--ink-2: #44465f;
--muted: #6b6e87;
--bg: #f7f7fb;
--surface: #ffffff;
--line: rgba(26, 26, 46, 0.1);
--ok: #13b981;
--danger: #e05656;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(26, 26, 46, 0.06), 0 2px 8px rgba(26, 26, 46, 0.05);
--sh-2: 0 12px 34px rgba(26, 26, 46, 0.14);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background:
radial-gradient(1100px 460px at 12% -8%, #ececfb 0%, rgba(236, 236, 251, 0) 60%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 28px 18px 56px;
}
.shell {
max-width: 880px;
margin: 0 auto;
}
/* ── Course bar ─────────────────────────────── */
.course-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.course-meta { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; }
.crumb { color: var(--ink); }
.crumb-dim { color: var(--muted); font-weight: 500; }
.sep { color: var(--line); }
.course-progress { display: flex; align-items: center; gap: 10px; }
.cp-label { font-size: 12.5px; color: var(--muted); font-weight: 600; }
.cp-track {
width: 130px; height: 7px; border-radius: 99px;
background: #e6e6f2; overflow: hidden;
}
.cp-fill {
height: 100%; border-radius: 99px;
background: linear-gradient(90deg, var(--accent), #3fd6a0);
}
.cp-pct { font-size: 12.5px; font-weight: 700; color: var(--accent); }
/* ── Stage ──────────────────────────────────── */
.stage {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: hidden;
}
.screen {
position: relative;
aspect-ratio: 16 / 9;
background:
radial-gradient(120% 120% at 70% 20%, #2b2b54 0%, #16162b 55%, #0e0e1d 100%);
color: #fff;
overflow: hidden;
}
.screen-art { position: absolute; inset: 0; }
.orbit {
position: absolute; border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.08);
}
.o1 { width: 320px; height: 320px; left: -60px; top: -90px; }
.o2 { width: 480px; height: 480px; right: -140px; bottom: -200px; border-color: rgba(91, 91, 214, 0.28); }
.screen-title {
position: absolute; left: 22px; top: 18px;
font-size: 13.5px; font-weight: 600; letter-spacing: .2px;
color: rgba(255, 255, 255, 0.78);
background: rgba(0, 0, 0, 0.28);
padding: 6px 11px; border-radius: 99px;
backdrop-filter: blur(4px);
}
.captions {
position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
max-width: 80%; text-align: center;
background: rgba(0, 0, 0, 0.74);
color: #fff; font-size: 15px; font-weight: 500;
padding: 6px 14px; border-radius: var(--r-sm);
}
.big-play {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 78px; height: 78px; border-radius: 50%;
border: none; cursor: pointer;
display: grid; place-items: center;
color: var(--brand);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
transition: transform .18s ease, opacity .25s ease, box-shadow .18s ease;
}
.big-play:hover { transform: translate(-50%, -50%) scale(1.07); box-shadow: 0 14px 38px rgba(0, 0, 0, 0.42); }
.big-play svg { margin-left: 4px; }
.screen.is-playing .big-play { opacity: 0; pointer-events: none; transform: translate(-50%, -50%) scale(.8); }
.rate-badge {
position: absolute; right: 18px; top: 18px;
background: var(--amber); color: #3a2700;
font-weight: 800; font-size: 13px;
padding: 4px 10px; border-radius: 99px;
}
/* ── Controls ───────────────────────────────── */
.controls {
padding: 12px 16px 14px;
background: linear-gradient(180deg, #fbfbff, var(--surface));
border-top: 1px solid var(--line);
}
.scrub-row { padding: 4px 2px 12px; }
.scrubber {
position: relative; height: 6px; border-radius: 99px;
background: #e3e3ef; cursor: pointer;
transition: height .14s ease;
}
.scrubber:hover, .scrubber:focus-visible { height: 9px; outline: none; }
.scrub-buffer {
position: absolute; left: 0; top: 0; height: 100%;
width: 0%; border-radius: 99px; background: #c9c9dd;
}
.scrub-played {
position: absolute; left: 0; top: 0; height: 100%;
width: 0%; border-radius: 99px;
background: linear-gradient(90deg, var(--brand), #7b7bf0);
}
.scrub-knob {
position: absolute; top: 50%; left: 0%;
width: 15px; height: 15px; border-radius: 50%;
background: #fff; border: 3px solid var(--brand);
transform: translate(-50%, -50%) scale(0);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22);
transition: transform .14s ease;
}
.scrubber:hover .scrub-knob,
.scrubber:focus-visible .scrub-knob,
.scrubber.is-drag .scrub-knob { transform: translate(-50%, -50%) scale(1); }
.scrub-tip {
position: absolute; bottom: 20px; left: 0;
transform: translateX(-50%);
background: var(--ink); color: #fff;
font-size: 12px; font-weight: 600;
padding: 3px 7px; border-radius: 6px;
white-space: nowrap; pointer-events: none;
}
.btn-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.btn-cluster { display: flex; align-items: center; gap: 4px; }
.btn-right { gap: 2px; }
.ctl {
display: inline-grid; place-items: center;
width: 38px; height: 38px;
border: none; border-radius: var(--r-sm);
background: transparent; color: var(--ink-2);
cursor: pointer;
transition: background .15s ease, color .15s ease, transform .12s ease;
}
.ctl svg { width: 22px; height: 22px; }
.ctl:hover { background: var(--brand-50); color: var(--brand-d); }
.ctl:active { transform: scale(.92); }
.ctl:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.ctl-text { width: auto; padding: 0 11px; font-weight: 700; font-size: 13px; }
/* play/pause icon swap */
.i-pause { display: none; }
#playBtn[data-state="playing"] .i-play { display: none; }
#playBtn[data-state="playing"] .i-pause { display: block; }
/* volume */
.vol { display: flex; align-items: center; gap: 2px; }
.i-mute { display: none; }
#muteBtn.is-muted .i-vol { display: none; }
#muteBtn.is-muted .i-mute { display: block; color: var(--danger); }
.vol-track {
position: relative; width: 0; opacity: 0;
height: 6px; border-radius: 99px; background: #e3e3ef;
cursor: pointer; transition: width .2s ease, opacity .2s ease;
}
.vol:hover .vol-track, .vol-track:focus-visible { width: 74px; opacity: 1; margin-right: 6px; }
.vol-fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: 99px; background: var(--brand); }
.vol-knob {
position: absolute; top: 50%; width: 12px; height: 12px; border-radius: 50%;
background: #fff; border: 2px solid var(--brand);
transform: translate(-50%, -50%);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.time { font-size: 13px; color: var(--muted); font-weight: 500; margin-left: 6px; white-space: nowrap; }
.time b { color: var(--ink); font-weight: 700; }
.ctl-cc[aria-pressed="true"] {
background: var(--brand); color: #fff;
}
.cc-glyph {
font-size: 11px; font-weight: 800; letter-spacing: .5px;
border: 1.5px solid currentColor; border-radius: 5px;
padding: 2px 4px; line-height: 1;
}
.i-fsx { display: none; }
.shell.is-fs .i-fs { display: none; }
.shell.is-fs .i-fsx { display: block; }
/* ── Menus ──────────────────────────────────── */
.menu-wrap { position: relative; }
.menu {
position: absolute; bottom: calc(100% + 10px); left: 50%;
transform: translateX(-50%) translateY(6px);
min-width: 168px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-2);
padding: 7px;
opacity: 0; pointer-events: none;
transition: opacity .15s ease, transform .15s ease;
z-index: 5;
}
.menu.menu-right { left: auto; right: 0; transform: translateY(6px); }
.menu:not([hidden]) { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
.menu.menu-right:not([hidden]) { transform: translateY(0); }
.menu[hidden] { display: block; }
.menu-head {
margin: 2px 8px 6px; font-size: 11.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
}
.menu-item {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%; text-align: left;
border: none; background: transparent; cursor: pointer;
font: inherit; font-size: 14px; font-weight: 500; color: var(--ink-2);
padding: 8px 10px; border-radius: var(--r-sm);
}
.menu-item:hover { background: var(--brand-50); color: var(--brand-d); }
.menu-item.is-on { color: var(--brand-d); font-weight: 700; }
.menu-item.is-on::after { content: "✓"; color: var(--accent); font-weight: 800; }
.q-note { font-size: 11px; font-weight: 600; color: var(--accent); }
/* ── Below ──────────────────────────────────── */
.below {
margin-top: 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
padding: 20px;
}
.lesson-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.lh-pills { display: flex; gap: 7px; margin-bottom: 9px; }
.pill {
font-size: 11.5px; font-weight: 700; padding: 3px 9px; border-radius: 99px;
}
.pill-level { background: #fdf0d8; color: #9a6a06; }
.pill-time { background: var(--brand-50); color: var(--brand-d); }
.lh-title { margin: 0; font-size: 21px; font-weight: 800; letter-spacing: -.3px; }
.lh-by { margin: 4px 0 0; color: var(--muted); font-size: 13.5px; font-weight: 500; }
.mark-done {
flex: none;
border: 1px solid var(--accent); color: var(--accent);
background: #effaf5;
font: inherit; font-weight: 700; font-size: 13.5px;
padding: 9px 15px; border-radius: var(--r-sm); cursor: pointer;
transition: background .15s ease, color .15s ease;
}
.mark-done:hover { background: var(--accent); color: #fff; }
.mark-done.is-done { background: var(--accent); color: #fff; border-color: var(--accent); }
.lessons { list-style: none; margin: 18px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.lesson {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: var(--r-md);
cursor: pointer; transition: background .14s ease;
}
.lesson:hover { background: #f4f4fb; }
.ck {
flex: none; width: 20px; height: 20px; border-radius: 50%;
border: 2px solid #d3d3e3; position: relative;
}
.lesson.is-done .ck { background: var(--accent); border-color: var(--accent); }
.lesson.is-done .ck::after {
content: ""; position: absolute; left: 6px; top: 2.5px;
width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.lesson.is-current { background: var(--brand-50); }
.lesson.is-current .ck { border-color: var(--brand); }
.lesson.is-current .ck::after {
content: ""; position: absolute; inset: 4px; border-radius: 50%; background: var(--brand);
}
.ln-no { flex: none; width: 24px; font-size: 12.5px; font-weight: 700; color: var(--muted); text-align: center; }
.ln-title { flex: 1; font-size: 14.5px; font-weight: 600; color: var(--ink); }
.lesson.is-done .ln-title { color: var(--ink-2); font-weight: 500; }
.lesson.is-current .ln-title { color: var(--brand-d); font-weight: 700; }
.ln-dur { flex: none; font-size: 12.5px; font-weight: 600; color: var(--muted); }
.hint {
margin: 16px 0 0; font-size: 12.5px; color: var(--muted);
border-top: 1px dashed var(--line); padding-top: 14px;
}
kbd {
font: inherit; font-size: 11px; font-weight: 700;
background: #f0f0f8; border: 1px solid var(--line);
border-bottom-width: 2px; border-radius: 5px;
padding: 1px 5px; color: var(--ink-2);
}
/* ── Toast ──────────────────────────────────── */
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translate(-50%, 24px);
background: var(--ink); color: #fff;
font-size: 13.5px; font-weight: 600;
padding: 11px 18px; border-radius: 99px;
box-shadow: var(--sh-2);
opacity: 0; pointer-events: none;
transition: opacity .22s ease, transform .22s ease;
z-index: 50;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ── Fullscreen visual ──────────────────────── */
.shell.is-fs .stage {
position: fixed; inset: 0; z-index: 40;
border-radius: 0; max-width: none;
display: flex; flex-direction: column;
}
.shell.is-fs .screen { flex: 1; aspect-ratio: auto; }
/* ── Responsive ─────────────────────────────── */
@media (max-width: 520px) {
body { padding: 16px 12px 44px; }
.course-progress { width: 100%; }
.cp-track { flex: 1; width: auto; }
.time { display: none; }
.vol:hover .vol-track { width: 56px; }
.lh-title { font-size: 18px; }
.lesson-head { flex-direction: column; }
.mark-done { align-self: flex-start; }
.ctl { width: 36px; height: 36px; }
.ctl-text { padding: 0 8px; }
.ln-title { font-size: 13.5px; }
.hint { line-height: 2; }
}(function () {
"use strict";
// ── Fictional lesson "playback" model ─────────────────────────
var DURATION = 760; // 12:40 in seconds
var state = {
time: 0,
playing: false,
buffered: 0,
volume: 0.8,
prevVolume: 0.8,
muted: false,
speed: 1,
captions: false,
quality: "720p",
seeking: false,
};
var captionLines = [
"Let's start by sketching the grid container and its children.",
"We reach for display: grid and a repeat() track template.",
"auto-fit lets the browser decide how many columns fit.",
"minmax(240px, 1fr) keeps each card readable on small screens.",
"Now we add gap so the cards breathe without extra margins.",
"Watch the layout reflow as the viewport narrows.",
"Finally we wrap it in a container query for true modularity.",
];
// ── Element refs ──────────────────────────────────────────────
var $ = function (id) { return document.getElementById(id); };
var screen = $("screen");
var bigPlay = $("bigPlay");
var playBtn = $("playBtn");
var nextBtn = $("nextBtn");
var muteBtn = $("muteBtn");
var fsBtn = $("fsBtn");
var ccBtn = $("ccBtn");
var shell = document.querySelector(".shell");
var scrubber = $("scrubber");
var scrubPlayed = $("scrubPlayed");
var scrubBuffer = $("scrubBuffer");
var scrubKnob = $("scrubKnob");
var scrubTip = $("scrubTip");
var volTrack = $("volTrack");
var volFill = $("volFill");
var volKnob = $("volKnob");
var curEl = $("cur");
var rateBadge = $("rateBadge");
var captions = $("captions");
var captionText = $("captionText");
var speedBtn = $("speedBtn");
var speedMenu = $("speedMenu");
var speedLabel = $("speedLabel");
var qualBtn = $("qualBtn");
var qualMenu = $("qualMenu");
var qualLabel = $("qualLabel");
var markDone = $("markDone");
var lessons = $("lessons");
var toastEl = $("toast");
// ── Helpers ───────────────────────────────────────────────────
function fmt(s) {
s = Math.max(0, Math.floor(s));
var m = Math.floor(s / 60);
var r = s % 60;
return m + ":" + (r < 10 ? "0" : "") + r;
}
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 1900);
}
// ── Render ────────────────────────────────────────────────────
function renderTime() {
var pct = (state.time / DURATION) * 100;
scrubPlayed.style.width = pct + "%";
scrubKnob.style.left = pct + "%";
curEl.textContent = fmt(state.time);
scrubber.setAttribute("aria-valuenow", Math.round(pct));
scrubber.setAttribute("aria-valuetext", fmt(state.time) + " of " + fmt(DURATION));
if (state.captions) {
var idx = Math.min(
captionLines.length - 1,
Math.floor((state.time / DURATION) * captionLines.length)
);
captionText.textContent = captionLines[idx];
}
}
function renderBuffer() {
scrubBuffer.style.width = (state.buffered / DURATION) * 100 + "%";
}
function renderVolume() {
var v = state.muted ? 0 : state.volume;
volFill.style.width = v * 100 + "%";
volKnob.style.left = v * 100 + "%";
volTrack.setAttribute("aria-valuenow", Math.round(v * 100));
muteBtn.classList.toggle("is-muted", state.muted || state.volume === 0);
}
// ── Tick loop ─────────────────────────────────────────────────
var lastTs = 0;
function loop(ts) {
if (state.playing) {
if (lastTs) {
var dt = ((ts - lastTs) / 1000) * state.speed;
state.time += dt;
if (state.time >= DURATION) {
state.time = DURATION;
setPlaying(false);
toast("Lesson finished — nice work!");
}
renderTime();
}
// simulate buffering a bit ahead
if (state.buffered < DURATION) {
state.buffered = Math.min(DURATION, Math.max(state.buffered, state.time + 45));
renderBuffer();
}
}
lastTs = ts;
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// ── Play / pause ──────────────────────────────────────────────
function setPlaying(on) {
if (on && state.time >= DURATION) state.time = 0;
state.playing = on;
screen.classList.toggle("is-playing", on);
playBtn.setAttribute("data-state", on ? "playing" : "paused");
playBtn.setAttribute("aria-label", on ? "Pause" : "Play");
bigPlay.setAttribute("aria-label", on ? "Pause lesson" : "Play lesson");
}
function togglePlay() { setPlaying(!state.playing); }
bigPlay.addEventListener("click", togglePlay);
playBtn.addEventListener("click", togglePlay);
// ── Next lesson ───────────────────────────────────────────────
function goNext() {
toast("Loading next lesson · Container Queries in Practice");
state.time = 0;
state.buffered = 0;
renderTime();
renderBuffer();
setPlaying(true);
}
nextBtn.addEventListener("click", goNext);
// ── Scrubber drag ─────────────────────────────────────────────
function seekFromEvent(clientX) {
var rect = scrubber.getBoundingClientRect();
var ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
state.time = ratio * DURATION;
renderTime();
}
function showTip(clientX) {
var rect = scrubber.getBoundingClientRect();
var ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
scrubTip.hidden = false;
scrubTip.style.left = ratio * 100 + "%";
scrubTip.textContent = fmt(ratio * DURATION);
}
scrubber.addEventListener("pointerdown", function (e) {
state.seeking = true;
scrubber.classList.add("is-drag");
scrubber.setPointerCapture(e.pointerId);
seekFromEvent(e.clientX);
showTip(e.clientX);
});
scrubber.addEventListener("pointermove", function (e) {
if (state.seeking) seekFromEvent(e.clientX);
if (e.buttons || state.seeking) showTip(e.clientX);
else if (e.pointerType === "mouse") showTip(e.clientX);
});
scrubber.addEventListener("pointerleave", function () {
if (!state.seeking) scrubTip.hidden = true;
});
scrubber.addEventListener("pointerup", function () {
state.seeking = false;
scrubber.classList.remove("is-drag");
scrubTip.hidden = true;
});
scrubber.addEventListener("keydown", function (e) {
if (e.key === "ArrowLeft") { state.time = Math.max(0, state.time - 5); renderTime(); e.preventDefault(); }
else if (e.key === "ArrowRight") { state.time = Math.min(DURATION, state.time + 5); renderTime(); e.preventDefault(); }
else if (e.key === "Home") { state.time = 0; renderTime(); e.preventDefault(); }
else if (e.key === "End") { state.time = DURATION; renderTime(); e.preventDefault(); }
});
// ── Volume ────────────────────────────────────────────────────
function setVolFromEvent(clientX) {
var rect = volTrack.getBoundingClientRect();
var ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
state.volume = ratio;
state.muted = ratio === 0;
renderVolume();
}
var volDrag = false;
volTrack.addEventListener("pointerdown", function (e) {
volDrag = true; volTrack.setPointerCapture(e.pointerId); setVolFromEvent(e.clientX);
});
volTrack.addEventListener("pointermove", function (e) { if (volDrag) setVolFromEvent(e.clientX); });
volTrack.addEventListener("pointerup", function () { volDrag = false; });
volTrack.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight" || e.key === "ArrowUp") { state.volume = Math.min(1, state.volume + 0.1); state.muted = false; renderVolume(); e.preventDefault(); }
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { state.volume = Math.max(0, state.volume - 0.1); renderVolume(); e.preventDefault(); }
});
function toggleMute() {
if (state.muted || state.volume === 0) {
state.muted = false;
if (state.volume === 0) state.volume = state.prevVolume || 0.8;
toast("Sound on");
} else {
state.prevVolume = state.volume;
state.muted = true;
toast("Muted");
}
renderVolume();
}
muteBtn.addEventListener("click", toggleMute);
// ── Menus ─────────────────────────────────────────────────────
function openMenu(menu, btn) {
closeMenus(menu);
menu.hidden = false;
btn.setAttribute("aria-expanded", "true");
}
function closeMenus(except) {
[speedMenu, qualMenu].forEach(function (m) {
if (m !== except && !m.hidden) {
m.hidden = true;
var b = m === speedMenu ? speedBtn : qualBtn;
b.setAttribute("aria-expanded", "false");
}
});
}
function toggleMenu(menu, btn) {
if (menu.hidden) openMenu(menu, btn);
else { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); }
}
speedBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleMenu(speedMenu, speedBtn); });
qualBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleMenu(qualMenu, qualBtn); });
speedMenu.addEventListener("click", function (e) {
var item = e.target.closest(".menu-item");
if (!item) return;
var sp = parseFloat(item.getAttribute("data-speed"));
setSpeed(sp, item);
speedMenu.hidden = true;
speedBtn.setAttribute("aria-expanded", "false");
});
qualMenu.addEventListener("click", function (e) {
var item = e.target.closest(".menu-item");
if (!item) return;
setQuality(item.getAttribute("data-qual"), item);
qualMenu.hidden = true;
qualBtn.setAttribute("aria-expanded", "false");
});
function setSpeed(sp, item) {
state.speed = sp;
speedLabel.textContent = sp === 1 ? "1×" : sp + "×";
speedMenu.querySelectorAll(".menu-item").forEach(function (m) {
var on = m === item;
m.classList.toggle("is-on", on);
m.setAttribute("aria-checked", on ? "true" : "false");
});
rateBadge.hidden = sp === 1;
rateBadge.textContent = sp + "×";
toast("Speed " + (sp === 1 ? "normal" : sp + "×"));
}
function setQuality(q, item) {
state.quality = q;
qualLabel.textContent = q === "Auto" ? "Auto" : q;
qualMenu.querySelectorAll(".menu-item").forEach(function (m) {
var on = m === item;
m.classList.toggle("is-on", on);
m.setAttribute("aria-checked", on ? "true" : "false");
});
toast("Quality · " + q);
}
document.addEventListener("click", function () { closeMenus(null); });
// ── Captions ──────────────────────────────────────────────────
function toggleCaptions() {
state.captions = !state.captions;
ccBtn.setAttribute("aria-pressed", state.captions ? "true" : "false");
captions.hidden = !state.captions;
if (state.captions) renderTime();
toast(state.captions ? "Captions on (English)" : "Captions off");
}
ccBtn.addEventListener("click", toggleCaptions);
// ── Fullscreen (visual only) ──────────────────────────────────
function toggleFs() {
shell.classList.toggle("is-fs");
var on = shell.classList.contains("is-fs");
fsBtn.setAttribute("aria-label", on ? "Exit fullscreen" : "Fullscreen");
document.body.style.overflow = on ? "hidden" : "";
toast(on ? "Fullscreen" : "Exited fullscreen");
}
fsBtn.addEventListener("click", toggleFs);
// ── Mark complete + lesson navigation ─────────────────────────
markDone.addEventListener("click", function () {
var done = markDone.classList.toggle("is-done");
markDone.textContent = done ? "Completed" : "Mark complete";
var cur = lessons.querySelector(".lesson.is-current");
if (cur) cur.classList.toggle("is-done", done);
toast(done ? "Lesson marked complete" : "Marked as not complete");
});
lessons.addEventListener("click", function (e) {
var li = e.target.closest(".lesson");
if (!li) return;
lessons.querySelectorAll(".lesson").forEach(function (l) { l.classList.remove("is-current"); l.removeAttribute("aria-current"); });
li.classList.add("is-current");
li.setAttribute("aria-current", "true");
li.classList.remove("is-done");
var title = li.querySelector(".ln-title").textContent;
state.time = 0; state.buffered = 0; renderTime(); renderBuffer();
setPlaying(true);
toast("Now playing · " + title);
});
// ── Keyboard shortcuts ────────────────────────────────────────
document.addEventListener("keydown", function (e) {
var tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea") return;
// let the sliders handle their own arrow keys
if ((e.target === scrubber || e.target === volTrack) &&
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].indexOf(e.key) > -1) return;
switch (e.key) {
case " ": case "k": togglePlay(); e.preventDefault(); break;
case "ArrowLeft": case "j": state.time = Math.max(0, state.time - 5); renderTime(); e.preventDefault(); break;
case "ArrowRight": case "l": state.time = Math.min(DURATION, state.time + 5); renderTime(); e.preventDefault(); break;
case "ArrowUp": state.volume = Math.min(1, state.volume + 0.1); state.muted = false; renderVolume(); e.preventDefault(); break;
case "ArrowDown": state.volume = Math.max(0, state.volume - 0.1); renderVolume(); e.preventDefault(); break;
case "m": case "M": toggleMute(); break;
case "c": case "C": toggleCaptions(); break;
case "f": case "F": toggleFs(); break;
case "n": case "N": goNext(); break;
case "Escape": if (shell.classList.contains("is-fs")) toggleFs(); closeMenus(null); break;
}
});
// ── Init ──────────────────────────────────────────────────────
state.buffered = 95;
renderTime();
renderBuffer();
renderVolume();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LMS — Lesson Player Controls</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="shell">
<header class="course-bar">
<div class="course-meta">
<span class="crumb">Frontend Foundations</span>
<span class="sep" aria-hidden="true">›</span>
<span class="crumb crumb-dim">Module 3 · Layout & Flexbox</span>
</div>
<div class="course-progress">
<span class="cp-label">Course progress</span>
<div class="cp-track" role="progressbar" aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" aria-label="Course progress 62 percent">
<div class="cp-fill" style="width:62%"></div>
</div>
<span class="cp-pct">62%</span>
</div>
</header>
<section class="stage" aria-label="Lesson player">
<!-- Fake video surface -->
<div class="screen" id="screen">
<div class="screen-art" aria-hidden="true">
<div class="orbit o1"></div>
<div class="orbit o2"></div>
<div class="screen-title">Lesson 14 · Building a Responsive Card Grid</div>
</div>
<div class="captions" id="captions" hidden>
<span id="captionText">Now we’ll wrap the grid in a container query so the cards reflow.</span>
</div>
<button class="big-play" id="bigPlay" aria-label="Play lesson">
<svg viewBox="0 0 24 24" width="34" height="34" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
</button>
<div class="rate-badge" id="rateBadge" hidden>1.5×</div>
</div>
<!-- Control bar -->
<div class="controls" id="controls">
<!-- Scrubber -->
<div class="scrub-row">
<div class="scrubber" id="scrubber" role="slider" tabindex="0"
aria-label="Seek lesson" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="scrub-buffer" id="scrubBuffer"></div>
<div class="scrub-played" id="scrubPlayed"></div>
<div class="scrub-knob" id="scrubKnob"></div>
<div class="scrub-tip" id="scrubTip" hidden>0:00</div>
</div>
</div>
<!-- Buttons -->
<div class="btn-row">
<div class="btn-cluster">
<button class="ctl" id="playBtn" aria-label="Play" data-state="paused">
<svg class="i-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
<svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z" fill="currentColor"/></svg>
</button>
<button class="ctl" id="nextBtn" aria-label="Next lesson">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5v14l9-7zM16 5h2.5v14H16z" fill="currentColor"/></svg>
</button>
<div class="vol" id="vol">
<button class="ctl" id="muteBtn" aria-label="Mute">
<svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/><path d="M16 8a5 5 0 0 1 0 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<svg class="i-mute" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/><path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<div class="vol-track" id="volTrack" role="slider" tabindex="0"
aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80">
<div class="vol-fill" id="volFill" style="width:80%"></div>
<div class="vol-knob" id="volKnob" style="left:80%"></div>
</div>
</div>
<span class="time" id="time"><b id="cur">0:00</b> / <span id="dur">12:40</span></span>
</div>
<div class="btn-cluster btn-right">
<!-- Speed -->
<div class="menu-wrap">
<button class="ctl ctl-text" id="speedBtn" aria-haspopup="true" aria-expanded="false" aria-label="Playback speed">
<span id="speedLabel">1×</span>
</button>
<div class="menu" id="speedMenu" role="menu" hidden>
<p class="menu-head">Playback speed</p>
<button class="menu-item" role="menuitemradio" data-speed="0.5">0.5×</button>
<button class="menu-item" role="menuitemradio" data-speed="0.75">0.75×</button>
<button class="menu-item is-on" role="menuitemradio" data-speed="1" aria-checked="true">Normal</button>
<button class="menu-item" role="menuitemradio" data-speed="1.25">1.25×</button>
<button class="menu-item" role="menuitemradio" data-speed="1.5">1.5×</button>
<button class="menu-item" role="menuitemradio" data-speed="2">2×</button>
</div>
</div>
<!-- Captions -->
<button class="ctl ctl-cc" id="ccBtn" aria-pressed="false" aria-label="Toggle captions">
<span class="cc-glyph">CC</span>
</button>
<!-- Quality -->
<div class="menu-wrap">
<button class="ctl ctl-text" id="qualBtn" aria-haspopup="true" aria-expanded="false" aria-label="Video quality">
<span id="qualLabel">720p</span>
</button>
<div class="menu menu-right" id="qualMenu" role="menu" hidden>
<p class="menu-head">Quality</p>
<button class="menu-item" role="menuitemradio" data-qual="Auto">Auto <span class="q-note">recommended</span></button>
<button class="menu-item" role="menuitemradio" data-qual="1080p">1080p</button>
<button class="menu-item is-on" role="menuitemradio" data-qual="720p" aria-checked="true">720p</button>
<button class="menu-item" role="menuitemradio" data-qual="480p">480p</button>
<button class="menu-item" role="menuitemradio" data-qual="240p">240p</button>
</div>
</div>
<!-- Fullscreen -->
<button class="ctl" id="fsBtn" aria-label="Fullscreen">
<svg class="i-fs" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<svg class="i-fsx" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
</div>
</section>
<!-- Up next + lesson list -->
<section class="below">
<div class="lesson-head">
<div>
<div class="lh-pills">
<span class="pill pill-level">Intermediate</span>
<span class="pill pill-time">12:40</span>
</div>
<h1 class="lh-title">Building a Responsive Card Grid</h1>
<p class="lh-by">with Mara Okonkwo · Module 3 of 6</p>
</div>
<button class="mark-done" id="markDone">Mark complete</button>
</div>
<ol class="lessons" id="lessons">
<li class="lesson is-done">
<span class="ck" aria-hidden="true"></span>
<span class="ln-no">12</span>
<span class="ln-title">CSS Grid vs Flexbox</span>
<span class="ln-dur">08:14</span>
</li>
<li class="lesson is-done">
<span class="ck" aria-hidden="true"></span>
<span class="ln-no">13</span>
<span class="ln-title">Auto-fit & minmax()</span>
<span class="ln-dur">10:02</span>
</li>
<li class="lesson is-current" aria-current="true">
<span class="ck" aria-hidden="true"></span>
<span class="ln-no">14</span>
<span class="ln-title">Building a Responsive Card Grid</span>
<span class="ln-dur">12:40</span>
</li>
<li class="lesson" data-next>
<span class="ck" aria-hidden="true"></span>
<span class="ln-no">15</span>
<span class="ln-title">Container Queries in Practice</span>
<span class="ln-dur">09:48</span>
</li>
<li class="lesson">
<span class="ck" aria-hidden="true"></span>
<span class="ln-no">16</span>
<span class="ln-title">Layout Debugging Toolkit</span>
<span class="ln-dur">07:33</span>
</li>
</ol>
<p class="hint">Shortcuts — <kbd>Space</kbd> play · <kbd>←</kbd>/<kbd>→</kbd> seek · <kbd>↑</kbd>/<kbd>↓</kbd> volume · <kbd>M</kbd> mute · <kbd>C</kbd> captions · <kbd>F</kbd> fullscreen · <kbd>N</kbd> next</p>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Lesson Player Controls
A complete lesson player control bar for a course platform. A stylized video surface sits above a control row with play/pause, next-lesson, a volume slider, the running time, a playback-speed menu, a captions toggle, a quality picker, and fullscreen. A course-progress meter and an up-next lesson list frame the player so it reads like a real learning screen rather than a bare widget.
The scrubber is fully interactive: a muted buffered range trails behind the brand-colored played range, the knob grows on hover, and dragging anywhere on the track seeks while a tooltip follows the pointer with the target timestamp. The speed and quality menus open as floating popovers with check-marked selections, captions paint timed lines onto the screen, and a speed badge appears whenever playback runs faster or slower than normal. “Mark complete” check-offs and clickable lesson rows mirror the progress conventions learners expect.
Everything runs on vanilla JavaScript with a small simulated playback clock — no media file, no libraries. Keyboard shortcuts cover the whole surface (Space to play, arrows to seek and adjust volume, and M/C/F/N for mute, captions, fullscreen and next), the sliders expose ARIA slider roles, and a tiny toast() helper confirms each action.
Illustrative UI only — fictional courses, not a real learning platform.