Streaming — Player Control Bar
A cinematic dark-first player control bar for the fictional Nebula streaming service, built in HTML, CSS, and vanilla JS. A draggable scrubber shows buffered range and a hover thumbnail tooltip with timecode, beside play and pause, skip ten, previous and next episode, a slide-out volume slider, captions, a multi-level quality and speed settings menu, picture-in-picture, and fullscreen. The chrome auto-hides during playback, an up-next chip surfaces near the end, and full keyboard shortcuts drive every control.
MCP
Code
:root {
--bg: #0b0b0f;
--surface: #15151c;
--surface-2: #1e1e27;
--ink: #f4f4f7;
--ink-2: #b6b7c3;
--muted: #83859a;
--brand: #e50914;
--accent: #ffffff;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.16);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1100px 600px at 78% -10%, #1a1024 0%, transparent 60%),
radial-gradient(900px 600px at 0% 110%, #0c1422 0%, transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: grid;
place-items: center;
padding: clamp(14px, 4vw, 48px);
}
.stage { width: min(960px, 100%); }
.stage__lede {
margin: 0 0 16px;
color: var(--ink-2);
font-size: 0.92rem;
letter-spacing: 0.01em;
}
/* ---------------- Player shell ---------------- */
.player {
position: relative;
aspect-ratio: 16 / 9;
width: 100%;
border-radius: var(--r-lg);
overflow: hidden;
background: #000;
box-shadow: var(--shadow);
outline: none;
isolation: isolate;
user-select: none;
}
.player:focus-visible { box-shadow: var(--shadow), 0 0 0 3px rgba(229, 9, 20, 0.6); }
.player.hide-cursor { cursor: none; }
.player__poster { position: absolute; inset: 0; z-index: 0; }
.player__art {
position: absolute; inset: 0;
background:
radial-gradient(120% 90% at 22% 18%, #3a2150 0%, transparent 55%),
radial-gradient(120% 120% at 88% 96%, #0b3b54 0%, transparent 55%),
linear-gradient(135deg, #1b1330 0%, #0d1726 60%, #060810 100%);
}
.player__art::after {
content: ""; position: absolute; inset: 0;
background:
repeating-linear-gradient(115deg, rgba(255,255,255,0.04) 0 2px, transparent 2px 26px),
radial-gradient(60% 50% at 70% 40%, rgba(255,180,90,0.16), transparent 70%);
mix-blend-mode: screen;
}
.player__vignette {
position: absolute; inset: 0;
background: radial-gradient(120% 90% at 50% 35%, transparent 40%, rgba(0,0,0,0.55) 100%);
}
/* Big center play */
.bigplay {
position: absolute; z-index: 5; inset: 0; margin: auto;
width: 86px; height: 86px; border-radius: 999px;
display: grid; place-items: center;
border: 1px solid var(--line-2);
background: rgba(10, 10, 14, 0.55);
backdrop-filter: blur(6px);
color: var(--ink); cursor: pointer;
transition: transform 0.25s var(--ease), background 0.25s, opacity 0.3s;
}
.bigplay svg { width: 40px; height: 40px; fill: currentColor; margin-left: 4px; }
.bigplay:hover { transform: scale(1.08); background: var(--brand); border-color: transparent; }
.player.playing .bigplay { opacity: 0; pointer-events: none; transform: scale(0.7); }
/* Buffering */
.buffering { position: absolute; inset: 0; z-index: 6; display: none; place-items: center; }
.player.buffering .buffering { display: grid; }
.player.buffering .bigplay { opacity: 0; }
.buffering__ring {
width: 56px; height: 56px; border-radius: 999px;
border: 4px solid rgba(255,255,255,0.18); border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------------- Top bar ---------------- */
.player__top {
position: absolute; top: 0; left: 0; right: 0; z-index: 8;
display: flex; align-items: center; gap: 14px;
padding: clamp(12px, 2.4vw, 22px);
background: linear-gradient(180deg, rgba(0,0,0,0.7), transparent);
transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}
.player__meta { flex: 1; min-width: 0; }
.player__kicker {
display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--brand);
}
.player__title {
margin: 2px 0 0; font-size: clamp(1rem, 2.4vw, 1.35rem); font-weight: 800;
letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge {
font-size: 0.66rem; font-weight: 800; letter-spacing: 0.06em;
padding: 4px 8px; border-radius: var(--r-sm); border: 1px solid var(--line-2);
color: var(--ink); background: rgba(0,0,0,0.4);
}
.badge--quality { background: linear-gradient(135deg, #f0c14b, #d49b1f); color: #1a1300; border-color: transparent; }
/* ---------------- Center cluster ---------------- */
.cluster {
position: absolute; z-index: 7; left: 50%; top: 50%; transform: translate(-50%, -50%);
display: flex; align-items: center; gap: clamp(20px, 6vw, 56px);
opacity: 0; pointer-events: none; transition: opacity 0.3s var(--ease);
}
.player.playing.show-ui .cluster { opacity: 1; pointer-events: auto; }
.cluster__btn {
position: relative; width: 48px; height: 48px; border-radius: 999px;
display: grid; place-items: center; cursor: pointer;
border: 1px solid var(--line); background: rgba(10,10,14,0.45);
color: var(--ink); transition: transform 0.2s var(--ease), background 0.2s;
}
.cluster__btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.cluster__btn span { position: absolute; font-size: 0.56rem; font-weight: 800; bottom: 12px; }
.cluster__btn:hover { background: rgba(255,255,255,0.14); transform: scale(1.08); }
.cluster__btn--main { width: 64px; height: 64px; background: rgba(229,9,20,0.92); border-color: transparent; }
.cluster__btn--main svg { width: 30px; height: 30px; fill: currentColor; stroke: none; }
.cluster__btn--main:hover { background: var(--brand); }
.cluster__btn--main .ico-pause { display: none; }
.player.playing .cluster__btn--main .ico-play { display: none; }
.player.playing .cluster__btn--main .ico-pause { display: block; }
/* ---------------- Bottom controls ---------------- */
.controls {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 9;
padding: clamp(8px, 2vw, 18px) clamp(12px, 2.4vw, 24px) clamp(12px, 2.2vw, 20px);
background: linear-gradient(0deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.45) 55%, transparent 100%);
transition: opacity 0.3s var(--ease), transform 0.35s var(--ease);
}
/* auto-hide */
.player:not(.show-ui) .controls { opacity: 0; transform: translateY(12px); pointer-events: none; }
.player:not(.show-ui) .player__top { opacity: 0; transform: translateY(-12px); }
/* ----- scrubber ----- */
.scrub {
position: relative; padding: 14px 0 8px; cursor: pointer; touch-action: none;
}
.scrub__track {
position: relative; height: 5px; border-radius: 999px;
background: rgba(255,255,255,0.22); overflow: visible;
transition: height 0.15s var(--ease);
}
.scrub:hover .scrub__track, .scrub.dragging .scrub__track { height: 8px; }
.scrub__buffered {
position: absolute; inset: 0 auto 0 0; width: 0%; border-radius: 999px;
background: rgba(255,255,255,0.4);
}
.scrub__played {
position: absolute; inset: 0 auto 0 0; width: 0%; border-radius: 999px;
background: var(--brand);
}
.scrub__handle {
position: absolute; top: 50%; left: 0; width: 14px; height: 14px;
border-radius: 999px; background: var(--brand);
transform: translate(-50%, -50%) scale(0);
box-shadow: 0 0 0 4px rgba(229,9,20,0.25);
transition: transform 0.15s var(--ease);
}
.scrub:hover .scrub__handle, .scrub.dragging .scrub__handle, .scrub:focus-visible .scrub__handle { transform: translate(-50%, -50%) scale(1); }
.scrub:focus-visible { outline: none; }
.scrub:focus-visible .scrub__track { box-shadow: 0 0 0 3px rgba(229,9,20,0.55); border-radius: 999px; }
/* hover thumbnail tooltip */
.scrub__tip {
position: absolute; bottom: 28px; left: 0; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 6px;
opacity: 0; pointer-events: none; transition: opacity 0.12s; will-change: left;
}
.scrub.show-tip .scrub__tip { opacity: 1; }
.scrub__thumb {
width: 142px; height: 80px; border-radius: var(--r-sm);
border: 2px solid rgba(255,255,255,0.85);
background:
radial-gradient(80% 80% at 30% 20%, #5a3b86 0%, transparent 60%),
radial-gradient(80% 80% at 80% 90%, #16607f 0%, transparent 60%),
linear-gradient(135deg, #241a3c, #0c1726);
box-shadow: 0 10px 30px rgba(0,0,0,0.6);
}
.scrub__tiptime {
font-size: 0.74rem; font-weight: 700; font-variant-numeric: tabular-nums;
background: rgba(0,0,0,0.78); padding: 3px 8px; border-radius: 6px; border: 1px solid var(--line);
}
/* ----- button bar ----- */
.bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.bar__group { display: flex; align-items: center; gap: clamp(2px, 1vw, 8px); min-width: 0; }
.bar__group--end { flex-shrink: 0; }
.iconbtn {
display: grid; place-items: center; width: 40px; height: 40px;
border: 0; border-radius: var(--r-sm); background: transparent; color: var(--ink);
cursor: pointer; position: relative; transition: background 0.18s, transform 0.18s var(--ease), color 0.18s;
}
.iconbtn svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.iconbtn:hover { background: rgba(255,255,255,0.13); }
.iconbtn:active { transform: scale(0.92); }
.iconbtn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.iconbtn .vol-wave { transition: opacity 0.15s; }
/* play/pause icon swap */
.iconbtn .ico-pause, .iconbtn .ico-mute, .iconbtn .ico-fs-exit { display: none; }
.iconbtn .ico-play, .iconbtn .ico-vol, .iconbtn .ico-fs { display: block; }
.player.playing #playToggle .ico-play { display: none; }
.player.playing #playToggle .ico-pause { display: block; }
.player.muted #muteToggle .ico-vol { display: none; }
.player.muted #muteToggle .ico-mute { display: block; }
.player.fs #fs .ico-fs { display: none; }
.player.fs #fs .ico-fs-exit { display: block; }
/* captions active */
#cc[aria-pressed="true"] { color: #fff; }
#cc[aria-pressed="true"]::after {
content: ""; position: absolute; left: 8px; right: 8px; bottom: 5px;
height: 2px; border-radius: 2px; background: var(--brand);
}
/* ----- volume ----- */
.vol { display: flex; align-items: center; }
.vol__slider {
width: 0; opacity: 0; overflow: hidden; touch-action: none;
transition: width 0.22s var(--ease), opacity 0.22s; cursor: pointer; height: 40px;
display: flex; align-items: center;
}
.vol:hover .vol__slider, .vol__slider:focus-visible, .vol.open .vol__slider { width: 78px; opacity: 1; }
.vol__track { position: relative; width: 64px; height: 4px; border-radius: 999px; background: rgba(255,255,255,0.25); margin: 0 7px; }
.vol__fill { position: absolute; inset: 0 auto 0 0; width: 80%; background: var(--ink); border-radius: 999px; }
.vol__handle { position: absolute; top: 50%; left: 80%; width: 12px; height: 12px; border-radius: 999px; background: #fff; transform: translate(-50%, -50%); box-shadow: 0 2px 6px rgba(0,0,0,0.5); }
.vol__slider:focus-visible { outline: none; }
.vol__slider:focus-visible .vol__track { box-shadow: 0 0 0 3px rgba(229,9,20,0.5); }
/* ----- time ----- */
.time {
font-size: 0.82rem; color: var(--ink-2); font-variant-numeric: tabular-nums;
white-space: nowrap; padding-left: 4px;
}
.time b { color: var(--ink); font-weight: 600; }
.time i { color: var(--muted); font-style: normal; margin: 0 1px; }
/* ----- up next ----- */
.upnext {
display: flex; flex-direction: column; line-height: 1.2; text-align: right;
padding-right: 6px; max-width: 150px; overflow: hidden;
}
.upnext__label { font-size: 0.6rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.upnext__title { font-size: 0.78rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ---------------- Settings menu ---------------- */
.menuwrap { position: relative; }
.menu {
position: absolute; bottom: calc(100% + 12px); right: 0; min-width: 230px;
background: rgba(20, 20, 27, 0.96); backdrop-filter: blur(14px);
border: 1px solid var(--line-2); border-radius: var(--r-md);
box-shadow: 0 18px 50px rgba(0,0,0,0.6); padding: 6px; z-index: 20;
animation: menuIn 0.16s var(--ease);
}
@keyframes menuIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.menu__head {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px 8px 4px; font-size: 0.74rem; font-weight: 700; color: var(--ink-2);
}
.menu__back { width: 20px; height: 20px; display: grid; place-items: center; border: 0; background: transparent; color: var(--ink); cursor: pointer; border-radius: 6px; }
.menu__back svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; }
.menu__back:hover { background: rgba(255,255,255,0.1); }
.menu__sep { height: 1px; background: var(--line); margin: 4px 6px; }
.menu__item {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 9px 12px; border: 0; border-radius: var(--r-sm);
background: transparent; color: var(--ink); font: inherit; font-size: 0.86rem;
text-align: left; cursor: pointer; transition: background 0.14s;
}
.menu__item:hover, .menu__item:focus-visible { background: rgba(255,255,255,0.1); outline: none; }
.menu__item .menu__val { margin-left: auto; color: var(--muted); font-size: 0.78rem; display: flex; align-items: center; gap: 6px; }
.menu__item .menu__chev { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; opacity: 0.7; }
.menu__item[aria-checked="true"] { color: #fff; }
.menu__item[aria-checked="true"] .menu__val { color: var(--brand); }
.menu__check { width: 17px; height: 17px; fill: none; stroke: var(--brand); stroke-width: 2.4; opacity: 0; }
.menu__item[aria-checked="true"] .menu__check { opacity: 1; }
/* ---------------- hint + toast ---------------- */
.hint {
margin: 16px 2px 0; color: var(--muted); font-size: 0.82rem; line-height: 2;
}
.hint kbd {
font-family: inherit; font-size: 0.74rem; font-weight: 700;
background: var(--surface-2); border: 1px solid var(--line-2); border-bottom-width: 2px;
border-radius: 6px; padding: 2px 6px; color: var(--ink-2); margin: 0 1px;
}
.toast {
position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 20px);
background: rgba(20,20,27,0.97); border: 1px solid var(--line-2); color: var(--ink);
padding: 11px 18px; border-radius: 999px; font-size: 0.86rem; font-weight: 600;
box-shadow: var(--shadow); opacity: 0; pointer-events: none; z-index: 60;
transition: opacity 0.25s, transform 0.25s var(--ease);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
/* ---------------- responsive ---------------- */
@media (max-width: 520px) {
body { padding: 10px; }
.stage__lede { font-size: 0.84rem; }
.vol:hover .vol__slider, .vol.open .vol__slider { width: 0; opacity: 0; }
.time { font-size: 0.74rem; }
.time i, #dur { display: none; }
.upnext { display: none; }
#prev, #pip { display: none; }
.iconbtn { width: 36px; height: 36px; }
.iconbtn svg { width: 21px; height: 21px; }
.cluster { gap: 22px; }
.player__top { gap: 10px; }
.scrub__thumb { width: 112px; height: 64px; }
.menu { min-width: 200px; }
}(function () {
"use strict";
/* ---------------- elements ---------------- */
const $ = (id) => document.getElementById(id);
const player = $("player");
const scrub = $("scrub");
const played = $("played");
const buffered = $("buffered");
const handle = $("handle");
const tip = $("tip");
const tipTime = $("tipTime");
const curEl = $("cur");
const durEl = $("dur");
const volSlider = $("volSlider");
const volFill = $("volFill");
const volHandle = $("volHandle");
const vol = $("vol");
const settingsBtn = $("settingsBtn");
const settingsMenu = $("settingsMenu");
const ccBtn = $("cc");
const upnext = $("upnext");
const qualityBadge = $("qualityBadge");
const toastEl = $("toast");
/* ---------------- state ---------------- */
const DURATION = 48 * 60 + 12; // 48:12 in seconds
const state = {
time: 0,
playing: false,
volume: 0.8,
prevVolume: 0.8,
muted: false,
captions: false,
buffering: false,
seeking: false,
};
const settings = { quality: "Auto (4K)", speed: "1x", audio: "English 5.1", subs: "English" };
/* ---------------- helpers ---------------- */
let toastT;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastT);
toastT = setTimeout(() => toastEl.classList.remove("show"), 1900);
}
function fmt(s) {
s = Math.max(0, Math.round(s));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mm = h ? String(m).padStart(2, "0") : String(m);
return (h ? h + ":" : "") + mm + ":" + String(sec).padStart(2, "0");
}
/* ---------------- render ---------------- */
function renderProgress() {
const pct = (state.time / DURATION) * 100;
played.style.width = pct + "%";
handle.style.left = pct + "%";
curEl.textContent = fmt(state.time);
scrub.setAttribute("aria-valuenow", Math.round(pct));
scrub.setAttribute("aria-valuetext", fmt(state.time) + " of " + fmt(DURATION));
// up-next surfaces near the end
const remaining = DURATION - state.time;
upnext.hidden = remaining > 40;
}
function renderVolume() {
const v = state.muted ? 0 : state.volume;
volFill.style.width = v * 100 + "%";
volHandle.style.left = v * 100 + "%";
volSlider.setAttribute("aria-valuenow", Math.round(v * 100));
player.classList.toggle("muted", state.muted || state.volume === 0);
}
function renderPlaying() {
player.classList.toggle("playing", state.playing);
const lbl = state.playing ? "Pause" : "Play";
$("playToggle").setAttribute("aria-label", lbl);
$("centerToggle").setAttribute("aria-label", lbl);
$("bigplay").setAttribute("aria-label", lbl);
}
durEl.textContent = fmt(DURATION);
renderProgress();
renderVolume();
/* ---------------- buffered simulation ---------------- */
let bufferedPct = 18;
function tickBuffer() {
const target = Math.min(100, (state.time / DURATION) * 100 + 22);
bufferedPct += (target - bufferedPct) * 0.12;
buffered.style.width = Math.min(100, bufferedPct) + "%";
}
/* ---------------- playback loop ---------------- */
let lastT = performance.now();
function loop(now) {
const dt = (now - lastT) / 1000;
lastT = now;
if (state.playing && !state.seeking && !state.buffering) {
state.time += dt;
if (state.time >= DURATION) {
state.time = DURATION;
play(false);
toast("Episode finished — Up next in 5s");
}
renderProgress();
}
tickBuffer();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
/* ---------------- play / pause ---------------- */
function play(on) {
state.playing = on;
renderPlaying();
if (on) showUI(true), scheduleHide();
else clearTimeout(hideT), player.classList.add("show-ui");
}
function toggle() {
play(!state.playing);
bumpUI();
}
["playToggle", "centerToggle", "bigplay"].forEach((id) =>
$(id).addEventListener("click", (e) => { e.stopPropagation(); toggle(); })
);
/* brief buffering flash when seeking far */
function flashBuffer(ms) {
state.buffering = true;
player.classList.add("buffering");
clearTimeout(flashBuffer._t);
flashBuffer._t = setTimeout(() => {
state.buffering = false;
player.classList.remove("buffering");
}, ms);
}
/* ---------------- skip ---------------- */
function seekBy(delta) {
state.time = Math.min(DURATION, Math.max(0, state.time + delta));
renderProgress();
bumpUI();
}
$("rewind").addEventListener("click", (e) => { e.stopPropagation(); seekBy(-10); toast("⏪ 10 seconds"); });
$("forward").addEventListener("click", (e) => { e.stopPropagation(); seekBy(10); toast("⏩ 10 seconds"); });
$("prev").addEventListener("click", (e) => { e.stopPropagation(); toast("◀ Previous episode"); });
$("next").addEventListener("click", (e) => { e.stopPropagation(); state.time = 0; renderProgress(); flashBuffer(700); toast("▶ Next episode · E5 Saltwater Vows"); });
$("back").addEventListener("click", (e) => { e.stopPropagation(); toast("Back to browse"); });
/* ---------------- scrubber ---------------- */
function pctFromX(clientX, el) {
const r = el.getBoundingClientRect();
return Math.min(1, Math.max(0, (clientX - r.left) / r.width));
}
function updateTip(p) {
const r = scrub.getBoundingClientRect();
tip.style.left = p * r.width + "px";
tipTime.textContent = fmt(p * DURATION);
}
scrub.addEventListener("pointermove", (e) => {
if (state.seeking) return;
scrub.classList.add("show-tip");
updateTip(pctFromX(e.clientX, scrub));
});
scrub.addEventListener("pointerleave", () => { if (!state.seeking) scrub.classList.remove("show-tip"); });
scrub.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.seeking = true;
scrub.classList.add("dragging", "show-tip");
scrub.setPointerCapture(e.pointerId);
applySeek(e.clientX);
});
scrub.addEventListener("pointermove", (e) => {
if (!state.seeking) return;
applySeek(e.clientX);
});
function endSeek(e) {
if (!state.seeking) return;
state.seeking = false;
scrub.classList.remove("dragging");
if (e && e.pointerType !== "mouse") scrub.classList.remove("show-tip");
flashBuffer(450);
bumpUI();
}
scrub.addEventListener("pointerup", endSeek);
scrub.addEventListener("pointercancel", endSeek);
function applySeek(clientX) {
const p = pctFromX(clientX, scrub);
state.time = p * DURATION;
updateTip(p);
renderProgress();
}
scrub.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 30 : 10;
if (e.key === "ArrowRight") { seekBy(step); e.preventDefault(); }
else if (e.key === "ArrowLeft") { seekBy(-step); e.preventDefault(); }
else if (e.key === "Home") { state.time = 0; renderProgress(); e.preventDefault(); }
else if (e.key === "End") { state.time = DURATION; renderProgress(); e.preventDefault(); }
});
/* ---------------- volume ---------------- */
function setVolume(v, announce) {
v = Math.min(1, Math.max(0, v));
state.volume = v;
state.muted = v === 0;
if (v > 0) state.prevVolume = v;
renderVolume();
if (announce) toast("Volume " + Math.round(v * 100) + "%");
}
function applyVol(clientX) { setVolume(pctFromX(clientX, volSlider)); }
volSlider.addEventListener("pointerdown", (e) => {
e.stopPropagation();
volSlider.setPointerCapture(e.pointerId);
volSlider._drag = true;
vol.classList.add("open");
applyVol(e.clientX);
});
volSlider.addEventListener("pointermove", (e) => { if (volSlider._drag) applyVol(e.clientX); });
const volUp = (e) => { volSlider._drag = false; vol.classList.remove("open"); };
volSlider.addEventListener("pointerup", volUp);
volSlider.addEventListener("pointercancel", volUp);
volSlider.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowUp") { setVolume(state.volume + 0.05, true); e.preventDefault(); }
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { setVolume(state.volume - 0.05, true); e.preventDefault(); }
});
function toggleMute() {
if (state.muted || state.volume === 0) setVolume(state.prevVolume || 0.5);
else { state.prevVolume = state.volume; setVolume(0); }
toast(state.muted ? "Muted" : "Unmuted");
bumpUI();
}
$("muteToggle").addEventListener("click", (e) => { e.stopPropagation(); toggleMute(); });
/* ---------------- captions ---------------- */
function toggleCaptions() {
state.captions = !state.captions;
ccBtn.setAttribute("aria-pressed", String(state.captions));
toast(state.captions ? "Subtitles on · " + settings.subs : "Subtitles off");
bumpUI();
}
ccBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleCaptions(); });
/* ---------------- settings menu ---------------- */
const MENUS = {
root: () => [
{ type: "item", label: "Quality", icon: chev, value: settings.quality, go: "quality" },
{ type: "item", label: "Speed", icon: chev, value: settings.speed, go: "speed" },
{ type: "item", label: "Audio", icon: chev, value: settings.audio, go: "audio" },
{ type: "item", label: "Subtitles", icon: chev, value: settings.subs, go: "subs" },
],
quality: ["Auto (4K)", "4K HDR", "1080p", "720p", "Data Saver"].map((q) => opt("quality", q)),
speed: ["0.5x", "0.75x", "1x", "1.25x", "1.5x", "2x"].map((q) => opt("speed", q)),
audio: ["English 5.1", "English Stereo", "Español 5.1", "Audio Description"].map((q) => opt("audio", q)),
subs: ["Off", "English", "English [CC]", "Español", "Français"].map((q) => opt("subs", q)),
};
const TITLES = { quality: "Quality", speed: "Playback speed", audio: "Audio", subs: "Subtitles" };
const chev = '<svg class="menu__chev" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>';
function opt(key, val) { return { type: "radio", key, val }; }
let menuOpen = false;
let menuPage = "root";
function buildMenu(page) {
menuPage = page;
let html = "";
if (page !== "root") {
html += '<div class="menu__head"><button class="menu__back" data-back aria-label="Back">' +
'<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg></button>' + TITLES[page] + "</div>";
html += '<div class="menu__sep"></div>';
}
const items = page === "root" ? MENUS.root() : MENUS[page];
items.forEach((it) => {
if (it.type === "item") {
html += `<button class="menu__item" role="menuitem" data-go="${it.go}">${it.label}` +
`<span class="menu__val">${it.value}${it.icon}</span></button>`;
} else {
const checked = settings[it.key] === it.val;
html += `<button class="menu__item" role="menuitemradio" aria-checked="${checked}" data-set="${it.key}" data-val="${it.val}">` +
`<svg class="menu__check" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>${it.val}</button>`;
}
});
settingsMenu.innerHTML = html;
}
function openMenu() {
menuOpen = true;
buildMenu("root");
settingsMenu.hidden = false;
settingsBtn.setAttribute("aria-expanded", "true");
const first = settingsMenu.querySelector(".menu__item");
if (first) first.focus();
bumpUI();
}
function closeMenu() {
menuOpen = false;
settingsMenu.hidden = true;
settingsBtn.setAttribute("aria-expanded", "false");
}
settingsBtn.addEventListener("click", (e) => { e.stopPropagation(); menuOpen ? closeMenu() : openMenu(); });
settingsMenu.addEventListener("click", (e) => {
e.stopPropagation();
const back = e.target.closest("[data-back]");
if (back) { buildMenu("root"); settingsMenu.querySelector(".menu__item").focus(); return; }
const go = e.target.closest("[data-go]");
if (go) { buildMenu(go.dataset.go); settingsMenu.querySelector(".menu__item").focus(); return; }
const set = e.target.closest("[data-set]");
if (set) {
const { set: key, val } = set.dataset;
settings[key] = val;
if (key === "quality") qualityBadge.textContent = val.includes("4K") ? "4K" : val.includes("1080") ? "HD" : "SD";
if (key === "subs") { state.captions = val !== "Off"; ccBtn.setAttribute("aria-pressed", String(state.captions)); }
buildMenu("root");
toast(TITLES[key] + ": " + val);
}
});
settingsMenu.addEventListener("keydown", (e) => {
if (e.key === "Escape") { closeMenu(); settingsBtn.focus(); }
});
document.addEventListener("click", () => { if (menuOpen) closeMenu(); });
/* ---------------- PiP / fullscreen ---------------- */
$("pip").addEventListener("click", (e) => { e.stopPropagation(); toast("Picture-in-picture"); });
$("fs").addEventListener("click", (e) => { e.stopPropagation(); toggleFullscreen(); });
function toggleFullscreen() {
if (!document.fullscreenElement) {
(player.requestFullscreen ? player.requestFullscreen() : Promise.reject()).catch(() => {
player.classList.toggle("fs"); // fallback visual toggle in sandboxed iframes
});
} else {
document.exitFullscreen && document.exitFullscreen();
}
}
document.addEventListener("fullscreenchange", () => {
player.classList.toggle("fs", !!document.fullscreenElement);
});
/* ---------------- auto-hide UI ---------------- */
let hideT;
function showUI(on) { player.classList.toggle("show-ui", on); player.classList.toggle("hide-cursor", !on && state.playing); }
function scheduleHide() {
clearTimeout(hideT);
if (!state.playing) return;
hideT = setTimeout(() => { if (!menuOpen && !state.seeking) showUI(false); }, 2800);
}
function bumpUI() { showUI(true); scheduleHide(); }
player.classList.add("show-ui");
["pointermove", "pointerdown"].forEach((ev) => player.addEventListener(ev, bumpUI));
player.addEventListener("click", (e) => {
// click on bare video area toggles playback
if (e.target === player || e.target.closest("#poster")) toggle();
});
/* ---------------- keyboard shortcuts ---------------- */
document.addEventListener("keydown", (e) => {
if (e.target.matches("input, textarea")) return;
const k = e.key.toLowerCase();
switch (k) {
case " ": case "k": toggle(); e.preventDefault(); break;
case "arrowright": if (document.activeElement !== scrub && document.activeElement !== volSlider) { seekBy(5); e.preventDefault(); } break;
case "arrowleft": if (document.activeElement !== scrub && document.activeElement !== volSlider) { seekBy(-5); e.preventDefault(); } break;
case "arrowup": if (document.activeElement !== volSlider) { setVolume(state.volume + 0.05, true); e.preventDefault(); } break;
case "arrowdown": if (document.activeElement !== volSlider) { setVolume(state.volume - 0.05, true); e.preventDefault(); } break;
case "m": toggleMute(); break;
case "c": toggleCaptions(); break;
case "f": toggleFullscreen(); break;
case "j": seekBy(-10); toast("⏪ 10 seconds"); break;
case "l": seekBy(10); toast("⏩ 10 seconds"); break;
case "n": $("next").click(); break;
default: return;
}
bumpUI();
});
// start ready, paused
renderPlaying();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nebula — Player Control Bar</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="stage">
<p class="stage__lede">Streaming player chrome — drag the scrubber, tweak volume, open settings, or use keyboard shortcuts.</p>
<!-- Player -->
<section class="player" id="player" aria-label="Video player" tabindex="0">
<!-- Faux video poster -->
<div class="player__poster" id="poster" aria-hidden="true">
<div class="player__art"></div>
<div class="player__vignette"></div>
</div>
<!-- Center play overlay -->
<button class="bigplay" id="bigplay" aria-label="Play">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" /></svg>
</button>
<!-- Buffering spinner -->
<div class="buffering" id="buffering" aria-hidden="true"><span class="buffering__ring"></span></div>
<!-- Top gradient w/ title -->
<header class="player__top" id="top">
<button class="iconbtn iconbtn--back" id="back" aria-label="Back to browse">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
</button>
<div class="player__meta">
<span class="player__kicker">S2 · E4</span>
<h1 class="player__title">The Lantern Tide</h1>
</div>
<span class="badge badge--quality" id="qualityBadge">4K</span>
</header>
<!-- Skip / center cluster -->
<div class="cluster" id="cluster">
<button class="cluster__btn" id="rewind" aria-label="Rewind 10 seconds">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 8V5l-5 4 5 4v-3a5 5 0 1 1-5 5"/></svg>
<span>10</span>
</button>
<button class="cluster__btn cluster__btn--main" id="centerToggle" aria-label="Play or pause">
<svg class="ico-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" /></svg>
<svg class="ico-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
</button>
<button class="cluster__btn" id="forward" aria-label="Forward 10 seconds">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 8V5l5 4-5 4v-3a5 5 0 1 0 5 5"/></svg>
<span>10</span>
</button>
</div>
<!-- Bottom control bar -->
<div class="controls" id="controls">
<!-- Scrubber -->
<div class="scrub" id="scrub" role="slider" tabindex="0"
aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="scrub__track">
<div class="scrub__buffered" id="buffered"></div>
<div class="scrub__played" id="played"></div>
<div class="scrub__handle" id="handle"></div>
</div>
<div class="scrub__tip" id="tip" aria-hidden="true">
<div class="scrub__thumb" id="thumb"></div>
<span class="scrub__tiptime" id="tipTime">0:00</span>
</div>
</div>
<!-- Buttons row -->
<div class="bar">
<div class="bar__group">
<button class="iconbtn" id="playToggle" aria-label="Play">
<svg class="ico-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" /></svg>
<svg class="ico-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
</button>
<button class="iconbtn" id="prev" aria-label="Previous episode">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h2v12H6zM20 6v12l-9-6z" /></svg>
</button>
<button class="iconbtn" id="next" aria-label="Next episode">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 6h2v12h-2zM4 6l9 6-9 6z" /></svg>
</button>
<!-- Volume -->
<div class="vol" id="vol">
<button class="iconbtn" id="muteToggle" aria-label="Mute">
<svg class="ico-vol" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path class="vol-wave" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8 8 0 0 1 0 12"/></svg>
<svg class="ico-mute" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M16 9l5 6M21 9l-5 6"/></svg>
</button>
<div class="vol__slider" id="volSlider" role="slider" tabindex="0"
aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80">
<div class="vol__track"><div class="vol__fill" id="volFill"></div><div class="vol__handle" id="volHandle"></div></div>
</div>
</div>
<span class="time" id="time"><b id="cur">0:00</b> <i>/</i> <span id="dur">48:12</span></span>
</div>
<div class="bar__group bar__group--end">
<span class="upnext" id="upnext" hidden>
<span class="upnext__label">Up next</span>
<span class="upnext__title">E5 · Saltwater Vows</span>
</span>
<button class="iconbtn" id="cc" aria-label="Subtitles and captions" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 13h3M14 13h3" stroke-width="2"/></svg>
</button>
<!-- Settings -->
<div class="menuwrap">
<button class="iconbtn" id="settingsBtn" aria-label="Settings" aria-haspopup="true" aria-expanded="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L16 2H8l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 3 12a7 7 0 0 0 .1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L8 22h8l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6A7 7 0 0 0 19 12z"/></svg>
</button>
<div class="menu" id="settingsMenu" role="menu" hidden></div>
</div>
<button class="iconbtn" id="pip" aria-label="Picture in picture">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor" stroke="none"/></svg>
</button>
<button class="iconbtn" id="fs" aria-label="Fullscreen">
<svg class="ico-fs" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
<svg class="ico-fs-exit" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg>
</button>
</div>
</div>
</div>
</section>
<p class="hint">
Shortcuts:
<kbd>Space</kbd> play/pause · <kbd>←</kbd>/<kbd>→</kbd> seek 5s · <kbd>↑</kbd>/<kbd>↓</kbd> volume ·
<kbd>M</kbd> mute · <kbd>C</kbd> captions · <kbd>F</kbd> fullscreen
</p>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Player Control Bar
A standalone streaming player control bar for Nebula, a fictional service. A gradient-scrim billboard stands in for the video, with a center play button, a back arrow, episode title, and a quality badge across the top. Once playing, the chrome auto-hides after a couple of seconds and the cursor disappears, returning the moment you move the pointer or press a key. Clicking the bare video area toggles playback, and a faux buffering spinner flashes whenever you jump across the timeline.
The bottom bar carries the full control set. The scrubber drags with a pointer, paints a lighter buffered range behind the red played portion, and floats a thumbnail tooltip with a live timecode as you hover or drag. To its left sit play/pause, previous/next episode, skip-ten controls, a volume button whose slider slides open on hover, and a tabular time readout. To the right are captions, a settings menu, picture-in-picture, and fullscreen — plus an Up next chip that appears as the episode nears its end. The settings menu drills from a root list into Quality, Speed, Audio, and Subtitles sub-pages with radio selection and a back button.
Everything is vanilla JS with no dependencies. The scrubber and volume slider are real ARIA sliders with keyboard support; Space/K toggle playback, arrows seek and adjust volume, and M, C, and F map to mute, captions, and fullscreen. A shared toast() helper confirms every illustrative action, the layout reflows to a mobile-first control set down to ~360px, and all motion is disabled under prefers-reduced-motion.
Illustrative UI only — fictional titles, not a real streaming service.