Streaming — Video Player
A full-screen cinematic video player UI for a fictional streaming service. Features a backdrop poster, center play, and a bottom control bar with a working scrubber showing buffered progress, time readout, hover volume, captions toggle, and a settings menu for quality, speed, and audio track. Controls auto-hide during playback, a skip-intro button appears over the intro, and a next-up card counts down near the end. Pure HTML, CSS, and vanilla JS.
MCP
Code
:root {
--bg: #0b0b0f;
--surface: #15151c;
--surface-2: #1e1e27;
--ink: #f4f4f7;
--ink-2: #b6b7c3;
--muted: #83859a;
--brand: #7b5bff;
--brand-2: #b06bff;
--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 60px rgba(0, 0, 0, 0.55);
--glow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 12px 40px rgba(123, 91, 255, 0.35);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button { font-family: inherit; }
/* ---------- Player shell ---------- */
.player {
position: relative;
width: 100%;
height: 100vh;
min-height: 480px;
overflow: hidden;
background: #000;
cursor: default;
user-select: none;
}
.player.hide-cursor { cursor: none; }
.stage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(120% 80% at 70% 20%, rgba(123, 91, 255, 0.35), transparent 60%),
radial-gradient(100% 90% at 20% 90%, rgba(255, 70, 120, 0.25), transparent 55%),
linear-gradient(160deg, #1a1330 0%, #0d0b18 45%, #060509 100%);
}
.backdrop::after {
content: "";
position: absolute;
inset: 0;
background:
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.03) 0 2px, transparent 2px 7px);
mix-blend-mode: overlay;
opacity: 0.6;
}
.grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.05;
background-image: radial-gradient(rgba(255, 255, 255, 0.6) 0.5px, transparent 0.5px);
background-size: 3px 3px;
}
/* progress-driven darken vignette */
.player::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(to top, rgba(0, 0, 0, 0.75) 0%, transparent 28%),
linear-gradient(to bottom, rgba(0, 0, 0, 0.55) 0%, transparent 22%);
}
/* ---------- Center play ---------- */
.center-play {
position: relative;
z-index: 3;
width: 92px;
height: 92px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: rgba(15, 15, 22, 0.55);
backdrop-filter: blur(10px);
color: var(--ink);
display: grid;
place-items: center;
cursor: pointer;
box-shadow: var(--glow);
transition: transform 0.18s ease, background 0.2s ease, opacity 0.25s ease;
}
.center-play:hover { transform: scale(1.07); background: rgba(123, 91, 255, 0.3); }
.center-play svg { width: 40px; height: 40px; fill: currentColor; }
.center-play .icon-pause { display: none; }
.player.playing .center-play {
opacity: 0;
pointer-events: none;
transform: scale(0.8);
}
.icon-pause, .icon-mute, .icon-fs-exit { display: none; }
/* ---------- Spinner ---------- */
.spinner {
position: absolute;
z-index: 4;
width: 56px;
height: 56px;
}
.spinner span {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
border: 3px solid var(--line-2);
border-top-color: var(--brand);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Top bar ---------- */
.topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 6;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.player.controls-hidden .topbar {
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
.back {
flex: none;
width: 42px;
height: 42px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(10, 10, 14, 0.5);
color: var(--ink);
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.2s ease;
}
.back:hover { background: rgba(255, 255, 255, 0.12); }
.back svg { width: 24px; height: 24px; fill: currentColor; }
.title-block { display: flex; flex-direction: column; min-width: 0; }
.title-block .series { font-weight: 700; font-size: 16px; letter-spacing: -0.01em; }
.title-block .ep {
font-size: 13px;
color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quality-tag {
margin-left: auto;
flex: none;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-2);
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(10, 10, 14, 0.5);
}
/* ---------- Skip intro ---------- */
.skip-intro {
position: absolute;
right: 26px;
bottom: 128px;
z-index: 7;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
font-size: 14px;
font-weight: 700;
color: var(--ink);
background: rgba(20, 20, 28, 0.82);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
cursor: pointer;
backdrop-filter: blur(8px);
box-shadow: var(--shadow);
animation: rise 0.3s ease both;
transition: background 0.2s ease, transform 0.15s ease;
}
.skip-intro:hover { background: var(--brand); transform: translateY(-2px); }
.skip-intro svg { width: 18px; height: 18px; fill: currentColor; }
@keyframes rise { from { opacity: 0; transform: translateY(12px); } }
/* ---------- Next-up card ---------- */
.nextup {
position: absolute;
right: 26px;
bottom: 128px;
z-index: 7;
width: 360px;
max-width: calc(100vw - 52px);
display: flex;
gap: 14px;
padding: 14px;
background: rgba(18, 18, 26, 0.92);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
animation: rise 0.35s ease both;
}
.nextup-art {
flex: none;
width: 96px;
height: 96px;
border-radius: var(--r-md);
background:
radial-gradient(80% 60% at 30% 20%, rgba(255, 70, 120, 0.6), transparent),
linear-gradient(150deg, #2a1840, #120c22);
border: 1px solid var(--line);
}
.nextup-body { min-width: 0; }
.nextup-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-2);
}
.nextup-kicker strong { color: var(--ink); }
.nextup-body h3 { margin: 4px 0 4px; font-size: 16px; letter-spacing: -0.01em; }
.nextup-body p {
margin: 0 0 10px;
font-size: 13px;
color: var(--ink-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.nextup-actions { display: flex; gap: 8px; }
.np-play {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: var(--r-sm);
background: var(--accent);
color: #0b0b0f;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.2s ease;
}
.np-play:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(255, 255, 255, 0.2); }
.np-play svg { width: 16px; height: 16px; fill: currentColor; }
.np-cancel {
padding: 8px 14px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: transparent;
color: var(--ink-2);
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.np-cancel:hover { background: rgba(255, 255, 255, 0.08); color: var(--ink); }
/* ---------- Captions ---------- */
.captions {
position: absolute;
left: 50%;
bottom: 122px;
transform: translateX(-50%);
z-index: 5;
max-width: 70%;
text-align: center;
font-size: clamp(16px, 2.4vw, 24px);
font-weight: 500;
line-height: 1.35;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.9);
transition: bottom 0.3s ease, opacity 0.2s ease;
pointer-events: none;
}
.captions span {
background: rgba(0, 0, 0, 0.45);
padding: 2px 10px;
border-radius: 6px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.captions.off { display: none; }
.player.controls-hidden .captions { bottom: 40px; }
/* ---------- Controls ---------- */
.controls {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 6;
padding: 0 22px 16px;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.player.controls-hidden .controls {
opacity: 0;
transform: translateY(12px);
pointer-events: none;
}
/* scrubber */
.scrub {
position: relative;
padding: 14px 0 10px;
cursor: pointer;
outline: none;
}
.scrub-track {
position: relative;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
transition: height 0.15s ease;
}
.scrub:hover .scrub-track,
.scrub:focus-visible .scrub-track,
.scrub.dragging .scrub-track { height: 7px; }
.scrub-buffer {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.35);
}
.scrub-played {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--brand-2));
}
.scrub-thumb {
position: absolute;
top: 50%;
left: 0;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transform: translate(-50%, -50%) scale(0);
box-shadow: 0 0 0 4px rgba(123, 91, 255, 0.4);
transition: transform 0.15s ease;
}
.scrub:hover .scrub-thumb,
.scrub:focus-visible .scrub-thumb,
.scrub.dragging .scrub-thumb { transform: translate(-50%, -50%) scale(1); }
.scrub:focus-visible { box-shadow: none; }
.scrub:focus-visible .scrub-track { box-shadow: 0 0 0 2px rgba(176, 107, 255, 0.5); }
.scrub-tooltip {
position: absolute;
bottom: 26px;
transform: translateX(-50%);
background: #000;
border: 1px solid var(--line-2);
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 3px 8px;
border-radius: 6px;
pointer-events: none;
white-space: nowrap;
}
.control-row {
display: flex;
align-items: center;
gap: 6px;
}
.cl, .cr { display: flex; align-items: center; gap: 4px; }
.cr { margin-left: auto; }
.btn {
flex: none;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: none;
border-radius: var(--r-sm);
background: transparent;
color: var(--ink);
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, transform 0.1s ease;
}
.btn:hover { background: rgba(255, 255, 255, 0.12); }
.btn:active { transform: scale(0.92); }
.btn svg { width: 24px; height: 24px; fill: currentColor; }
.btn:focus-visible { outline: 2px solid var(--brand-2); outline-offset: 2px; }
.play-toggle .icon-pause,
.player.playing .play-toggle .icon-play { display: none; }
.player.playing .play-toggle .icon-pause { display: block; }
.cc.is-off { color: var(--muted); }
.cc {
position: relative;
}
.cc::after {
content: "";
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 2px;
border-radius: 2px;
background: var(--brand-2);
transition: opacity 0.2s ease;
}
.cc.is-off::after { opacity: 0; }
/* volume */
.vol { display: flex; align-items: center; }
.vol .icon-mute { display: none; }
.vol.muted .icon-vol { display: none; }
.vol.muted .icon-mute { display: block; }
.vol-slider {
width: 0;
opacity: 0;
height: 4px;
margin-left: 0;
cursor: pointer;
transition: width 0.2s ease, opacity 0.2s ease, margin 0.2s ease;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.25);
border-radius: 999px;
}
.vol:hover .vol-slider,
.vol:focus-within .vol-slider { width: 84px; opacity: 1; margin: 0 8px 0 2px; }
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
}
.vol-slider::-moz-range-thumb {
width: 13px; height: 13px; border: none; border-radius: 50%; background: #fff;
}
.time {
margin-left: 6px;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.time-sep { color: var(--muted); margin: 0 2px; }
.next-ep { position: relative; }
.next-ep.pulse { color: var(--brand-2); }
/* ---------- Settings menu ---------- */
.menu-wrap { position: relative; }
.menu {
position: absolute;
bottom: 52px;
right: 0;
width: 240px;
background: rgba(18, 18, 26, 0.96);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
overflow: hidden;
animation: pop 0.16s ease both;
}
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(0.97); } }
.menu-head {
display: flex;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.menu-tab {
flex: 1;
padding: 11px 8px;
border: none;
background: transparent;
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
transition: color 0.2s ease;
}
.menu-tab.is-active { color: var(--ink); box-shadow: inset 0 -2px 0 var(--brand-2); }
.menu-tab:hover { color: var(--ink-2); }
.menu-pane { padding: 6px; }
.menu-opt {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border: none;
border-radius: var(--r-sm);
background: transparent;
color: var(--ink-2);
font-size: 13.5px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
}
.menu-opt em { color: var(--muted); font-style: normal; font-size: 12px; }
.menu-opt:hover { background: rgba(255, 255, 255, 0.07); color: var(--ink); }
.menu-opt.is-active { color: var(--ink); }
.menu-opt.is-active::before {
content: "✓";
margin-right: -2px;
color: var(--brand-2);
font-weight: 800;
}
.menu-opt:not(.is-active) { padding-left: 26px; }
.badge {
margin-left: auto;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
background: rgba(123, 91, 255, 0.25);
color: var(--brand-2);
}
.menu-opt em + .badge, .menu-opt .badge { margin-left: auto; }
/* ---------- Toast ---------- */
.toast {
position: absolute;
left: 50%;
bottom: 132px;
z-index: 9;
transform: translate(-50%, 14px);
background: rgba(10, 10, 14, 0.92);
border: 1px solid var(--line-2);
color: var(--ink);
font-size: 13px;
font-weight: 600;
padding: 9px 16px;
border-radius: 999px;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.player { min-height: 100vh; }
.topbar { padding: 14px 14px; gap: 10px; }
.quality-tag { display: none; }
.title-block .series { font-size: 14px; }
.title-block .ep { font-size: 11px; }
.controls { padding: 0 12px 12px; }
.btn { width: 38px; height: 38px; }
.btn svg { width: 22px; height: 22px; }
.center-play { width: 76px; height: 76px; }
.rewind, #rewind, #forward { display: none; }
.vol:hover .vol-slider, .vol:focus-within .vol-slider { width: 60px; }
.time { font-size: 12px; }
.nextup { right: 12px; left: 12px; bottom: 116px; width: auto; }
.skip-intro { right: 12px; bottom: 116px; }
.menu { right: -6px; width: 220px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}(function () {
"use strict";
// ---- Simulated playback model ----
const DURATION = 2645; // seconds (~44 min episode)
const INTRO_START = 12;
const INTRO_END = 78;
const NEXTUP_AT = DURATION - 26;
const player = document.getElementById("player");
const stage = document.getElementById("stage");
const spinner = document.getElementById("spinner");
const centerPlay = document.getElementById("centerPlay");
const playToggle = document.getElementById("playToggle");
const rewind = document.getElementById("rewind");
const forward = document.getElementById("forward");
const scrub = document.getElementById("scrub");
const scrubBuffer = document.getElementById("scrubBuffer");
const scrubPlayed = document.getElementById("scrubPlayed");
const scrubThumb = document.getElementById("scrubThumb");
const scrubTip = document.getElementById("scrubTip");
const timeEl = document.getElementById("time");
const curEl = timeEl.firstChild;
const durEl = document.getElementById("dur");
const vol = document.getElementById("vol");
const muteBtn = document.getElementById("muteBtn");
const volSlider = document.getElementById("volSlider");
const ccBtn = document.getElementById("ccBtn");
const captions = document.getElementById("captions");
const settingsBtn = document.getElementById("settingsBtn");
const settingsMenu = document.getElementById("settingsMenu");
const skipIntro = document.getElementById("skipIntro");
const nextup = document.getElementById("nextup");
const nextCount = document.getElementById("nextCount");
const nextPlay = document.getElementById("nextPlay");
const nextCancel = document.getElementById("nextCancel");
const nextEpBtn = document.getElementById("nextEpBtn");
const fsBtn = document.getElementById("fsBtn");
const backBtn = document.getElementById("backBtn");
const toastEl = document.getElementById("toast");
let current = 0;
let buffered = 24;
let playing = false;
let muted = false;
let lastVol = 80;
let ccOn = true;
let speed = 1;
let dragging = false;
let nextupActive = false;
let nextupTimer = null;
let nextupRemain = 8;
// ---- Helpers ----
function fmt(s) {
s = Math.max(0, Math.floor(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") : m;
return (h ? h + ":" : "") + mm + ":" + String(sec).padStart(2, "0");
}
let toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 1700);
}
// ---- Caption track ----
const cues = [
[80, 86, "Nebula Originals presents"],
[86, 92, "Aurora Drift"],
[120, 126, "— You feel that? The hull's singing again."],
[126, 133, "That's not the hull. That's the rift answering us."],
[180, 187, "Set a course. We follow the signal."],
[240, 248, "Whatever's out there… it knew we were coming."]
];
function updateCaptions() {
if (!ccOn) { captions.innerHTML = ""; return; }
const cue = cues.find((c) => current >= c[0] && current < c[1]);
captions.innerHTML = cue ? "<span>" + cue[2] + "</span>" : "";
}
// ---- Render ----
function render() {
const pct = (current / DURATION) * 100;
scrubPlayed.style.width = pct + "%";
scrubThumb.style.left = pct + "%";
scrubBuffer.style.width = Math.min(100, buffered) + "%";
scrub.setAttribute("aria-valuenow", Math.round(pct));
scrub.setAttribute("aria-valuetext", fmt(current) + " of " + fmt(DURATION));
curEl.textContent = fmt(current) + " ";
durEl.textContent = fmt(DURATION);
skipIntro.hidden = !(current >= INTRO_START && current < INTRO_END);
if (current >= NEXTUP_AT && !nextupActive) showNextUp();
if (current < NEXTUP_AT && nextupActive) hideNextUp(false);
updateCaptions();
}
// ---- Playback tick ----
let tickId = null;
function startTick() {
if (tickId) return;
let last = performance.now();
const loop = (now) => {
const dt = (now - last) / 1000;
last = now;
if (playing && !dragging) {
current = Math.min(DURATION, current + dt * speed);
if (buffered < 100) buffered = Math.min(100, Math.max(buffered, (current / DURATION) * 100 + 14));
if (current >= DURATION) pause();
render();
}
tickId = requestAnimationFrame(loop);
};
tickId = requestAnimationFrame(loop);
}
// ---- Play / pause with buffering sim ----
let bufferTimer = null;
function play() {
if (current >= DURATION) current = 0;
spinner.hidden = false;
centerPlay.setAttribute("aria-label", "Pause");
clearTimeout(bufferTimer);
bufferTimer = setTimeout(() => {
spinner.hidden = true;
playing = true;
player.classList.add("playing");
playToggle.setAttribute("aria-label", "Pause");
startTick();
scheduleHide();
}, 480);
}
function pause() {
playing = false;
spinner.hidden = true;
player.classList.remove("playing");
playToggle.setAttribute("aria-label", "Play");
centerPlay.setAttribute("aria-label", "Play");
showControls();
}
function togglePlay() { playing ? pause() : play(); }
// ---- Seeking ----
function seekTo(sec) {
current = Math.max(0, Math.min(DURATION, sec));
if (current > buffered / 100 * DURATION) buffered = Math.min(100, (current / DURATION) * 100 + 6);
render();
}
function pointerToTime(clientX) {
const r = scrub.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
return ratio * DURATION;
}
scrub.addEventListener("pointerdown", (e) => {
dragging = true;
scrub.classList.add("dragging");
scrub.setPointerCapture(e.pointerId);
seekTo(pointerToTime(e.clientX));
});
scrub.addEventListener("pointermove", (e) => {
const t = pointerToTime(e.clientX);
scrubTip.hidden = false;
scrubTip.textContent = fmt(t);
const r = scrub.getBoundingClientRect();
scrubTip.style.left = (e.clientX - r.left) + "px";
if (dragging) seekTo(t);
});
scrub.addEventListener("pointerleave", () => { if (!dragging) scrubTip.hidden = true; });
scrub.addEventListener("pointerup", (e) => {
dragging = false;
scrub.classList.remove("dragging");
scrubTip.hidden = true;
try { scrub.releasePointerCapture(e.pointerId); } catch (_) {}
});
scrub.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 60 : 10;
if (e.key === "ArrowRight") { seekTo(current + step); e.preventDefault(); }
else if (e.key === "ArrowLeft") { seekTo(current - step); e.preventDefault(); }
else if (e.key === "Home") { seekTo(0); e.preventDefault(); }
else if (e.key === "End") { seekTo(DURATION); e.preventDefault(); }
});
// ---- Controls bindings ----
centerPlay.addEventListener("click", togglePlay);
playToggle.addEventListener("click", togglePlay);
stage.addEventListener("click", (e) => {
if (e.target === stage || e.target.classList.contains("backdrop") || e.target.classList.contains("grain")) {
togglePlay();
}
});
rewind.addEventListener("click", () => { seekTo(current - 10); toast("− 10s"); showControls(); });
forward.addEventListener("click", () => { seekTo(current + 10); toast("+ 10s"); showControls(); });
// volume
function applyVolume() {
muted = lastVol === 0;
vol.classList.toggle("muted", muted || volSlider.value === "0");
volSlider.value = muted ? 0 : lastVol;
}
muteBtn.addEventListener("click", () => {
if (muted || lastVol === 0) {
lastVol = lastVol === 0 ? 80 : lastVol;
muted = false;
volSlider.value = lastVol;
vol.classList.remove("muted");
toast("Unmuted");
} else {
muted = true;
vol.classList.add("muted");
toast("Muted");
}
});
volSlider.addEventListener("input", () => {
lastVol = +volSlider.value;
muted = lastVol === 0;
vol.classList.toggle("muted", muted);
});
// captions
ccBtn.addEventListener("click", () => {
ccOn = !ccOn;
ccBtn.classList.toggle("is-off", !ccOn);
ccBtn.setAttribute("aria-pressed", String(ccOn));
captions.classList.toggle("off", !ccOn);
toast(ccOn ? "Subtitles: English" : "Subtitles off");
updateCaptions();
});
// ---- Settings menu ----
function openMenu() {
settingsMenu.hidden = false;
settingsBtn.setAttribute("aria-expanded", "true");
}
function closeMenu() {
settingsMenu.hidden = true;
settingsBtn.setAttribute("aria-expanded", "false");
}
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
settingsMenu.hidden ? openMenu() : closeMenu();
});
document.addEventListener("click", (e) => {
if (!settingsMenu.hidden && !settingsMenu.contains(e.target) && e.target !== settingsBtn) closeMenu();
});
// tabs
settingsMenu.querySelectorAll(".menu-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const pane = tab.dataset.pane;
settingsMenu.querySelectorAll(".menu-tab").forEach((t) => t.classList.toggle("is-active", t === tab));
settingsMenu.querySelectorAll(".menu-pane").forEach((p) => { p.hidden = p.dataset.pane !== pane; });
});
});
// options
settingsMenu.querySelectorAll(".menu-pane").forEach((pane) => {
pane.querySelectorAll(".menu-opt").forEach((opt) => {
opt.addEventListener("click", () => {
pane.querySelectorAll(".menu-opt").forEach((o) => {
o.classList.toggle("is-active", o === opt);
o.setAttribute("aria-checked", String(o === opt));
});
if (opt.dataset.q) toast("Quality: " + opt.dataset.q);
if (opt.dataset.s) { speed = +opt.dataset.s; toast("Speed: " + (speed === 1 ? "Normal" : speed + "×")); }
if (opt.dataset.a) toast("Audio: " + opt.textContent.trim());
keepAlive();
});
});
});
// skip intro
skipIntro.addEventListener("click", () => {
seekTo(INTRO_END);
skipIntro.hidden = true;
toast("Skipped intro");
if (!playing) play();
});
// ---- Next-up ----
function showNextUp() {
nextupActive = true;
nextup.hidden = false;
nextupRemain = 8;
nextCount.textContent = nextupRemain;
nextEpBtn.classList.add("pulse");
clearInterval(nextupTimer);
nextupTimer = setInterval(() => {
nextupRemain -= 1;
nextCount.textContent = Math.max(0, nextupRemain);
if (nextupRemain <= 0) playNext();
}, 1000);
}
function hideNextUp(silent) {
nextupActive = false;
nextup.hidden = true;
nextEpBtn.classList.remove("pulse");
clearInterval(nextupTimer);
if (!silent) toast("Staying on this episode");
}
function playNext() {
clearInterval(nextupTimer);
nextup.hidden = true;
nextupActive = false;
nextEpBtn.classList.remove("pulse");
current = 0;
buffered = 18;
document.querySelector(".title-block .ep").textContent = "S2 · E5 — “Signal Lost”";
toast("Now playing: S2 · E5");
render();
play();
}
nextPlay.addEventListener("click", playNext);
nextCancel.addEventListener("click", () => hideNextUp(false));
nextEpBtn.addEventListener("click", playNext);
// ---- Fullscreen ----
fsBtn.addEventListener("click", () => {
const exit = player.classList.contains("is-fs");
fsBtn.querySelector(".icon-fs").style.display = exit ? "" : "none";
fsBtn.querySelector(".icon-fs-exit").style.display = exit ? "none" : "block";
try {
if (exit && document.fullscreenElement) document.exitFullscreen();
else if (!exit && player.requestFullscreen) player.requestFullscreen();
} catch (_) {}
player.classList.toggle("is-fs");
toast(exit ? "Exited fullscreen" : "Fullscreen");
});
backBtn.addEventListener("click", () => toast("Returning to browse…"));
// ---- Auto-hide controls ----
let hideTimer = null;
function showControls() {
player.classList.remove("controls-hidden", "hide-cursor");
}
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (playing && !dragging && settingsMenu.hidden && !nextupActive) {
player.classList.add("controls-hidden", "hide-cursor");
}
}, 3000);
}
function keepAlive() { showControls(); if (playing) scheduleHide(); }
player.addEventListener("pointermove", keepAlive);
player.addEventListener("pointerdown", keepAlive);
player.addEventListener("focusin", keepAlive);
// ---- Keyboard shortcuts ----
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
switch (e.key) {
case " ":
case "k": e.preventDefault(); togglePlay(); keepAlive(); break;
case "ArrowRight": seekTo(current + 10); keepAlive(); break;
case "ArrowLeft": seekTo(current - 10); keepAlive(); break;
case "ArrowUp": lastVol = Math.min(100, lastVol + 5); volSlider.value = lastVol; applyVolume(); keepAlive(); break;
case "ArrowDown": lastVol = Math.max(0, lastVol - 5); volSlider.value = lastVol; applyVolume(); keepAlive(); break;
case "m": muteBtn.click(); break;
case "c": ccBtn.click(); break;
case "f": fsBtn.click(); break;
case "n": playNext(); break;
case "Escape": if (!settingsMenu.hidden) closeMenu(); break;
}
});
// ---- Init ----
render();
startTick();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nebula — Player</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="player" id="player" aria-label="Video player">
<!-- Backdrop / poster art -->
<div class="stage" id="stage">
<div class="backdrop" aria-hidden="true"></div>
<div class="grain" aria-hidden="true"></div>
<!-- Buffering spinner -->
<div class="spinner" id="spinner" hidden aria-hidden="true">
<span></span>
</div>
<!-- Big center play -->
<button class="center-play" id="centerPlay" aria-label="Play">
<svg viewBox="0 0 24 24" class="icon-play" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg viewBox="0 0 24 24" class="icon-pause" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
</button>
</div>
<!-- Top bar (auto-hides) -->
<header class="topbar" id="topbar">
<button class="back" id="backBtn" aria-label="Back to browse">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15.4 7.4 14 6l-6 6 6 6 1.4-1.4L10.8 12z"/></svg>
</button>
<div class="title-block">
<span class="series">Aurora Drift</span>
<span class="ep">S2 · E4 — “The Long Dark Between”</span>
</div>
<div class="quality-tag">4K · Dolby Vision</div>
</header>
<!-- Skip intro -->
<button class="skip-intro" id="skipIntro" hidden>
Skip Intro
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>
</button>
<!-- Next-up card near the end -->
<aside class="nextup" id="nextup" hidden aria-label="Next episode">
<div class="nextup-art" aria-hidden="true"></div>
<div class="nextup-body">
<span class="nextup-kicker">Up next in <strong id="nextCount">8</strong>s</span>
<h3>S2 · E5 — “Signal Lost”</h3>
<p>The crew traces a ghost transmission to the edge of the rift.</p>
<div class="nextup-actions">
<button class="np-play" id="nextPlay">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
Play
</button>
<button class="np-cancel" id="nextCancel">Cancel</button>
</div>
</div>
</aside>
<!-- Bottom control bar (auto-hides) -->
<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-buffer" id="scrubBuffer"></div>
<div class="scrub-played" id="scrubPlayed"></div>
<div class="scrub-thumb" id="scrubThumb"></div>
</div>
<div class="scrub-tooltip" id="scrubTip" hidden>0:00</div>
</div>
<div class="control-row">
<div class="cl">
<button class="btn play-toggle" id="playToggle" aria-label="Play">
<svg viewBox="0 0 24 24" class="icon-play" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg viewBox="0 0 24 24" class="icon-pause" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
</button>
<button class="btn" id="rewind" aria-label="Rewind 10 seconds">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"/></svg>
</button>
<button class="btn" id="forward" aria-label="Forward 10 seconds">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5V1l5 5-5 5V7a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z"/></svg>
</button>
<div class="vol" id="vol">
<button class="btn" id="muteBtn" aria-label="Mute">
<svg viewBox="0 0 24 24" class="icon-vol" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4zm12 3a3 3 0 0 0-2-2.8v5.6A3 3 0 0 0 16 12zm-2-7.3v2.1a5 5 0 0 1 0 10.4v2.1a7 7 0 0 0 0-14.6z"/></svg>
<svg viewBox="0 0 24 24" class="icon-mute" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4zm15.5 3 2.3-2.3-1.4-1.4-2.3 2.3-2.3-2.3-1.4 1.4 2.3 2.3-2.3 2.3 1.4 1.4 2.3-2.3 2.3 2.3 1.4-1.4z"/></svg>
</button>
<input class="vol-slider" id="volSlider" type="range" min="0" max="100" value="80" aria-label="Volume" />
</div>
<span class="time" id="time">0:00 <span class="time-sep">/</span> <span id="dur">0:00</span></span>
</div>
<div class="cr">
<button class="btn cc" id="ccBtn" aria-label="Subtitles" aria-pressed="true">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm4 6H5v2h2v-2zm0-3H5v2h2V8zm12 3H9v2h10v-2zm0-3H9v2h10V8z"/></svg>
</button>
<div class="menu-wrap">
<button class="btn" id="settingsBtn" aria-label="Settings" aria-haspopup="true" aria-expanded="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19.4 13a7.8 7.8 0 0 0 0-2l2.1-1.6-2-3.4-2.5 1a7.4 7.4 0 0 0-1.7-1l-.4-2.6h-4l-.4 2.6c-.6.2-1.2.6-1.7 1l-2.5-1-2 3.4L4.6 11a7.8 7.8 0 0 0 0 2l-2.1 1.6 2 3.4 2.5-1c.5.4 1.1.8 1.7 1l.4 2.6h4l.4-2.6c.6-.2 1.2-.6 1.7-1l2.5 1 2-3.4-2.1-1.6zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z"/></svg>
</button>
<div class="menu" id="settingsMenu" role="menu" hidden>
<div class="menu-head">
<button class="menu-tab is-active" data-pane="quality" role="tab">Quality</button>
<button class="menu-tab" data-pane="speed" role="tab">Speed</button>
<button class="menu-tab" data-pane="audio" role="tab">Audio</button>
</div>
<div class="menu-pane" data-pane="quality">
<button class="menu-opt is-active" data-q="Auto" role="menuitemradio">Auto <em>1080p</em></button>
<button class="menu-opt" data-q="2160p" role="menuitemradio">2160p <span class="badge">4K</span></button>
<button class="menu-opt" data-q="1080p" role="menuitemradio">1080p <span class="badge">HD</span></button>
<button class="menu-opt" data-q="720p" role="menuitemradio">720p</button>
<button class="menu-opt" data-q="480p" role="menuitemradio">480p</button>
</div>
<div class="menu-pane" data-pane="speed" hidden>
<button class="menu-opt" data-s="0.5" role="menuitemradio">0.5×</button>
<button class="menu-opt" data-s="0.75" role="menuitemradio">0.75×</button>
<button class="menu-opt is-active" data-s="1" role="menuitemradio">Normal</button>
<button class="menu-opt" data-s="1.25" role="menuitemradio">1.25×</button>
<button class="menu-opt" data-s="1.5" role="menuitemradio">1.5×</button>
<button class="menu-opt" data-s="2" role="menuitemradio">2×</button>
</div>
<div class="menu-pane" data-pane="audio" hidden>
<button class="menu-opt is-active" data-a="en" role="menuitemradio">English <em>5.1</em></button>
<button class="menu-opt" data-a="en-ad" role="menuitemradio">English — Audio Description</button>
<button class="menu-opt" data-a="es" role="menuitemradio">Español <em>Stereo</em></button>
<button class="menu-opt" data-a="ja" role="menuitemradio">日本語 <em>Stereo</em></button>
</div>
</div>
</div>
<button class="btn next-ep" id="nextEpBtn" aria-label="Next episode">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5l8.5 7L6 19V5zm10 0h2v14h-2V5z"/></svg>
</button>
<button class="btn" id="fsBtn" aria-label="Fullscreen">
<svg viewBox="0 0 24 24" class="icon-fs" aria-hidden="true"><path d="M7 9V7h2V5H5v4h2zm10 0h2V5h-4v2h2v2zM7 15H5v4h4v-2H7v-2zm12 0h-2v2h-2v2h4v-4z"/></svg>
<svg viewBox="0 0 24 24" class="icon-fs-exit" aria-hidden="true"><path d="M9 9V5H7v2H5v2h4zm6 0h4V7h-2V5h-2v4zM9 15H5v2h2v2h2v-4zm10 0h-4v4h2v-2h2v-2z"/></svg>
</button>
</div>
</div>
</div>
<!-- Captions -->
<div class="captions" id="captions" aria-live="polite"></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script src="script.js"></script>
</body>
</html>Video Player
A full-screen, dark cinematic player for the fictional series Aurora Drift. A gradient poster backdrop sits behind a center play button; a minimal top bar shows the series, episode, and a 4K / Dolby Vision quality tag. A simulated playback engine drives everything — pressing play shows a brief buffering spinner, then advances the timeline in real time while the buffered bar fills ahead of the playhead.
The bottom control bar carries a draggable scrubber with a hover tooltip and keyboard seeking, a play/pause toggle, ±10s rewind and forward, a volume control that expands on hover, a live time readout, a captions toggle that renders styled subtitle cues, and a settings menu with tabbed Quality, Speed, and Audio panes. Controls and cursor auto-hide after a few seconds of playback and reappear on any pointer or focus activity.
Context-aware overlays complete the experience: a Skip Intro button surfaces while the intro plays, and a Next-Up card slides in near the end with a live countdown, Play, and Cancel actions. Keyboard shortcuts (space/k, arrows, m, c, f, n) mirror common player conventions, and the layout reflows cleanly down to mobile widths.
Illustrative UI only — fictional titles, not a real streaming service.