Video — Player Custom
A cinematic custom video player with letterboxed hero, amber-accented scrub timeline showing buffered ranges, play/pause, volume with mute, playback speed, chapters, fullscreen and a monospace timecode readout. Fully keyboard-driven with space, arrows, J/K/L, M, F and number-key seeking, plus an animated overlay control bar that auto-hides during playback and a chapter rail that highlights the active segment.
MCP
Code
:root {
--bg: #0a0a0b;
--surface: #141416;
--surface-2: #1c1c1f;
--amber: #ffb020;
--amber-d: #e6971a;
--red: #ff4d4d;
--ink: #f4f4f6;
--muted: #8a8a92;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--white: #fff;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 500px at 50% -10%, rgba(255, 176, 32, 0.10), transparent 60%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
padding: 34px 20px 60px;
}
.stage {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
/* ---------- Masthead ---------- */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.brand { display: flex; align-items: center; gap: 13px; }
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: var(--r-md);
background: linear-gradient(150deg, var(--amber), var(--amber-d));
color: #1a1200;
font-size: 15px;
box-shadow: 0 8px 22px rgba(255, 176, 32, 0.28);
}
.brand-kicker {
margin: 0;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--amber);
font-weight: 600;
}
.brand-title { margin: 1px 0 0; font-size: 20px; font-weight: 800; letter-spacing: -0.01em; }
.masthead-meta { display: flex; gap: 8px; }
.chip {
padding: 6px 11px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--muted);
}
.chip-mono { font-family: var(--mono); color: var(--ink); }
/* ---------- Player ---------- */
.player {
position: relative;
border-radius: var(--r-lg);
overflow: hidden;
background: #000;
aspect-ratio: 16 / 9;
border: 1px solid var(--line);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
user-select: none;
}
.video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: #000;
display: block;
}
/* Letterbox bars */
.bars {
position: absolute;
left: 0;
right: 0;
height: 9%;
background: #000;
z-index: 3;
pointer-events: none;
}
.bars-top { top: 0; }
.bars-bottom { bottom: 0; }
/* Title overlay */
.title-overlay {
position: absolute;
top: calc(9% + 12px);
left: 18px;
z-index: 4;
display: flex;
align-items: center;
gap: 9px;
font-size: 12.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.7);
opacity: 1;
transition: opacity 0.35s ease;
}
.player.hide-ui .title-overlay { opacity: 0; }
.rec-dot {
width: 9px; height: 9px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.55);
animation: pulse 1.9s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(255, 77, 77, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0); }
}
/* Big play */
.big-play {
position: absolute;
z-index: 5;
top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(1);
width: 74px; height: 74px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: rgba(10, 10, 11, 0.55);
backdrop-filter: blur(6px);
color: var(--white);
cursor: pointer;
display: grid;
place-items: center;
padding-left: 4px;
transition: transform 0.25s ease, background 0.2s ease, opacity 0.25s ease, box-shadow 0.2s;
}
.big-play:hover { background: var(--amber); color: #1a1200; box-shadow: 0 10px 30px rgba(255, 176, 32, 0.4); transform: translate(-50%, -50%) scale(1.05); }
.big-play:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
.player.playing .big-play { opacity: 0; pointer-events: none; transform: translate(-50%, -50%) scale(0.7); }
/* Skip flash */
.skip-flash {
position: absolute;
z-index: 5;
top: 50%;
transform: translateY(-50%);
padding: 12px 15px;
border-radius: 999px;
background: rgba(10, 10, 11, 0.6);
border: 1px solid var(--line-2);
font-family: var(--mono);
font-size: 13px;
color: var(--amber);
opacity: 0;
pointer-events: none;
}
.skip-left { left: 8%; }
.skip-right { right: 8%; }
.skip-flash.show { animation: flash 0.6s ease; }
@keyframes flash {
0% { opacity: 0; transform: translateY(-50%) scale(0.8); }
30% { opacity: 1; transform: translateY(-50%) scale(1); }
100% { opacity: 0; transform: translateY(-50%) scale(1); }
}
/* ---------- Controls ---------- */
.controls {
position: absolute;
z-index: 6;
left: 0; right: 0; bottom: 0;
padding: 42px 16px 16px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.5) 55%, transparent 100%);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.player.hide-ui .controls { opacity: 0; transform: translateY(8px); pointer-events: none; }
/* Scrub */
.scrub {
position: relative;
height: 16px;
margin-bottom: 8px;
cursor: pointer;
display: flex;
align-items: center;
}
.scrub::before {
content: "";
position: absolute;
left: 0; right: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
transition: height 0.15s ease;
}
.scrub-buffered, .scrub-played {
position: absolute;
left: 0;
height: 4px;
border-radius: 999px;
transition: height 0.15s ease;
}
.scrub-buffered { background: rgba(255, 255, 255, 0.28); width: 0; }
.scrub-played {
background: linear-gradient(90deg, var(--amber-d), var(--amber));
width: 0;
box-shadow: 0 0 10px rgba(255, 176, 32, 0.55);
}
.scrub-thumb {
position: absolute;
left: 0;
width: 13px; height: 13px;
border-radius: 50%;
background: var(--white);
border: 3px solid var(--amber);
transform: translateX(-50%) scale(0);
transition: transform 0.15s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.scrub:hover::before, .scrub.active::before,
.scrub:hover .scrub-buffered, .scrub.active .scrub-buffered,
.scrub:hover .scrub-played, .scrub.active .scrub-played { height: 6px; }
.scrub:hover .scrub-thumb, .scrub:focus-visible .scrub-thumb, .scrub.active .scrub-thumb { transform: translateX(-50%) scale(1); }
.scrub:focus-visible { outline: none; }
.scrub:focus-visible::before { box-shadow: 0 0 0 3px rgba(255, 176, 32, 0.4); }
.scrub-markers { position: absolute; left: 0; right: 0; height: 100%; pointer-events: none; }
.marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 2px; height: 9px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.5);
}
.scrub-bubble {
position: absolute;
bottom: 22px;
transform: translateX(-50%);
padding: 3px 7px;
border-radius: var(--r-sm);
background: #000;
border: 1px solid var(--line-2);
font-size: 11px;
color: var(--white);
opacity: 0;
pointer-events: none;
white-space: nowrap;
transition: opacity 0.12s ease;
}
.scrub-bubble-mono { font-family: var(--mono); }
.scrub:hover .scrub-bubble, .scrub.active .scrub-bubble { opacity: 1; }
/* Control row */
.control-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.ctl-group { display: flex; align-items: center; gap: 4px; }
.ctl {
display: inline-grid;
place-items: center;
height: 38px;
min-width: 38px;
padding: 0 8px;
border: none;
border-radius: var(--r-sm);
background: transparent;
color: var(--ink);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease;
}
.ctl svg { width: 20px; height: 20px; display: block; }
.ctl:hover { background: rgba(255, 255, 255, 0.10); color: var(--amber); }
.ctl:active { transform: scale(0.92); }
.ctl:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
.ctl-play:hover { background: var(--amber); color: #1a1200; }
.ctl-text { gap: 7px; font-size: 12.5px; font-weight: 600; }
.ctl-text span { white-space: nowrap; }
/* play/pause icon toggle */
.ic-pause, .ic-mute, .ic-fs-exit { display: none; }
.player.playing .ctl-play .ic-play { display: none; }
.player.playing .ctl-play .ic-pause { display: block; }
.vol.muted .ic-vol, :fullscreen .ic-fs { display: none; }
.vol.muted .ic-mute { display: block; }
/* Volume */
.vol { display: flex; align-items: center; }
.vol-range {
-webkit-appearance: none;
appearance: none;
width: 0;
opacity: 0;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 999px;
transition: width 0.22s ease, opacity 0.22s ease;
cursor: pointer;
}
.vol:hover .vol-range, .vol:focus-within .vol-range { width: 74px; opacity: 1; margin: 0 6px 0 2px; }
.vol-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--amber);
cursor: pointer;
box-shadow: 0 0 0 3px rgba(255, 176, 32, 0.2);
}
.vol-range::-moz-range-thumb {
width: 12px; height: 12px; border: none;
border-radius: 50%; background: var(--amber); cursor: pointer;
}
/* Timecode */
.timecode {
font-family: var(--mono);
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
margin-left: 6px;
color: var(--ink);
white-space: nowrap;
}
.tc-sep { color: var(--muted); margin: 0 3px; }
.tc-dim { color: var(--muted); }
/* Menus */
.menu { position: relative; }
.menu-panel {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
margin: 0;
padding: 6px;
list-style: none;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
opacity: 0;
visibility: hidden;
transform: translateY(6px);
transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s;
max-height: 210px;
overflow-y: auto;
z-index: 8;
}
.menu-panel-narrow { min-width: 110px; }
.menu.open .menu-panel { opacity: 1; visibility: visible; transform: translateY(0); }
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--r-sm);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.12s ease;
}
.menu-item:hover, .menu-item:focus-visible { background: var(--surface-2); outline: none; }
.menu-item .mi-time { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.menu-item.active { color: var(--amber); }
.menu-item.active .mi-time { color: var(--amber); }
.menu-item .mi-check { color: var(--amber); opacity: 0; font-size: 12px; }
.menu-item.active .mi-check { opacity: 1; }
.speed-mono { font-family: var(--mono); font-size: 13px; font-weight: 600; }
/* ---------- Chapter rail ---------- */
.rail {
margin-top: 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 16px 18px;
}
.rail-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; }
.rail-head h2 { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: 0.02em; }
.rail-count { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.rail-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.rail-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: var(--r-md);
background: var(--surface-2);
color: var(--ink);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.1s ease;
}
.rail-item:hover { border-color: var(--line-2); transform: translateX(2px); }
.rail-item:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
.rail-item.active { border-color: rgba(255, 176, 32, 0.5); background: rgba(255, 176, 32, 0.09); }
.rail-num {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: var(--muted);
width: 24px;
flex-shrink: 0;
}
.rail-item.active .rail-num { color: var(--amber); }
.rail-body { flex: 1; min-width: 0; }
.rail-name { font-size: 13.5px; font-weight: 600; }
.rail-desc { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-time {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
flex-shrink: 0;
}
.rail-item.active .rail-time { color: var(--amber); }
.rail-bar {
height: 3px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
margin-top: 6px;
display: none;
}
.rail-item.active .rail-bar { display: block; }
.rail-bar span { display: block; height: 100%; background: var(--amber); width: 0; }
/* ---------- Legend ---------- */
.legend {
margin-top: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: rgba(20, 20, 22, 0.6);
}
.legend-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.16em; color: var(--amber); font-weight: 600; margin-right: 4px; }
.keys { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 8px; }
.keys span { font-size: 12px; color: var(--muted); margin-right: 6px; }
kbd {
font-family: var(--mono);
font-size: 11px;
padding: 3px 7px;
border-radius: var(--r-sm);
background: var(--surface-2);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
color: var(--ink);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
padding: 11px 18px;
border-radius: var(--r-md);
background: var(--surface);
border: 1px solid var(--line-2);
color: var(--ink);
font-size: 13px;
font-weight: 500;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast .t-mono { font-family: var(--mono); color: var(--amber); }
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
body { padding: 22px 12px 44px; }
.brand-title { font-size: 17px; }
.masthead-meta .chip:first-child { display: none; }
.controls { padding: 34px 10px 12px; }
.ctl { min-width: 34px; height: 34px; padding: 0 5px; }
.ctl svg { width: 18px; height: 18px; }
.backBtn, #backBtn, #fwdBtn { display: none; }
.ctl-text span { display: none; }
.timecode { font-size: 11px; margin-left: 3px; }
.vol:hover .vol-range, .vol:focus-within .vol-range { width: 54px; }
.big-play { width: 60px; height: 60px; }
.rail-desc { display: none; }
.legend { gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}(function () {
"use strict";
var player = document.getElementById("player");
var video = document.getElementById("video");
var bigPlay = document.getElementById("bigPlay");
var playBtn = document.getElementById("playBtn");
var backBtn = document.getElementById("backBtn");
var fwdBtn = document.getElementById("fwdBtn");
var muteBtn = document.getElementById("muteBtn");
var volRange = document.getElementById("volRange");
var volWrap = document.getElementById("vol");
var fsBtn = document.getElementById("fsBtn");
var pipBtn = document.getElementById("pipBtn");
var scrub = document.getElementById("scrub");
var buffered = document.getElementById("buffered");
var played = document.getElementById("played");
var thumb = document.getElementById("thumb");
var bubble = document.getElementById("bubble");
var markers = document.getElementById("markers");
var curEl = document.getElementById("current");
var durEl = document.getElementById("duration");
var chapMenu = document.getElementById("chapMenu");
var chapBtn = document.getElementById("chapBtn");
var chapPanel = document.getElementById("chapPanel");
var speedMenu = document.getElementById("speedMenu");
var speedBtn = document.getElementById("speedBtn");
var speedPanel = document.getElementById("speedPanel");
var speedLabel = document.getElementById("speedLabel");
var railList = document.getElementById("railList");
var railCount = document.getElementById("railCount");
var toastEl = document.getElementById("toast");
var skipLeft = document.getElementById("skipLeft");
var skipRight = document.getElementById("skipRight");
/* ---- Chapters (fractions of duration, resolved once metadata loads) ---- */
var CHAPTERS = [
{ f: 0.0, name: "Cold Open", desc: "Establishing wide, no dialogue" },
{ f: 0.16, name: "Golden Hour", desc: "Backlit dolly across the field" },
{ f: 0.34, name: "The Reveal", desc: "Rack focus to the subject" },
{ f: 0.55, name: "Chase Cut", desc: "Handheld, rapid intercutting" },
{ f: 0.74, name: "Quiet Beat", desc: "Slow push-in, ambient score" },
{ f: 0.88, name: "End Card", desc: "Fade to credits, title lockup" }
];
var SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
var DUR = 0;
var scrubbing = false;
var uiTimer = null;
/* ---------------- helpers ---------------- */
function pad(n) { return (n < 10 ? "0" : "") + n; }
function fmt(t) {
if (!isFinite(t) || t < 0) t = 0;
t = Math.floor(t);
var h = Math.floor(t / 3600);
var m = Math.floor((t % 3600) / 60);
var s = t % 60;
return h > 0 ? h + ":" + pad(m) + ":" + pad(s) : pad(m) + ":" + pad(s);
}
var toastTimer;
function toast(msg) {
toastEl.innerHTML = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 1500);
}
function flash(el) {
el.classList.remove("show");
void el.offsetWidth;
el.classList.add("show");
}
/* ---------------- UI auto-hide ---------------- */
function showUI() {
player.classList.remove("hide-ui");
clearTimeout(uiTimer);
if (!video.paused) {
uiTimer = setTimeout(function () {
if (!video.paused && !anyMenuOpen()) player.classList.add("hide-ui");
}, 2200);
}
}
function anyMenuOpen() {
return chapMenu.classList.contains("open") || speedMenu.classList.contains("open");
}
player.addEventListener("mousemove", showUI);
player.addEventListener("mouseleave", function () {
if (!video.paused && !anyMenuOpen()) player.classList.add("hide-ui");
});
["focusin", "touchstart"].forEach(function (ev) { player.addEventListener(ev, showUI); });
/* ---------------- play / pause ---------------- */
function togglePlay() {
if (video.paused) { video.play().catch(function () {}); }
else { video.pause(); }
}
video.addEventListener("play", function () {
player.classList.add("playing");
playBtn.setAttribute("aria-label", "Pause");
playBtn.setAttribute("aria-pressed", "true");
showUI();
});
video.addEventListener("pause", function () {
player.classList.remove("playing");
player.classList.remove("hide-ui");
playBtn.setAttribute("aria-label", "Play");
playBtn.setAttribute("aria-pressed", "false");
clearTimeout(uiTimer);
});
bigPlay.addEventListener("click", togglePlay);
playBtn.addEventListener("click", togglePlay);
video.addEventListener("click", togglePlay);
function skip(sec) {
video.currentTime = Math.min(Math.max(0, video.currentTime + sec), DUR || video.duration || 0);
flash(sec < 0 ? skipLeft : skipRight);
}
backBtn.addEventListener("click", function () { skip(-10); });
fwdBtn.addEventListener("click", function () { skip(10); });
/* ---------------- volume ---------------- */
function applyVolumeUI() {
var muted = video.muted || video.volume === 0;
volWrap.classList.toggle("muted", muted);
muteBtn.setAttribute("aria-pressed", muted ? "true" : "false");
volRange.value = video.muted ? 0 : video.volume;
}
volRange.addEventListener("input", function () {
video.volume = parseFloat(volRange.value);
video.muted = video.volume === 0;
applyVolumeUI();
});
muteBtn.addEventListener("click", function () {
video.muted = !video.muted;
applyVolumeUI();
toast(video.muted ? "Muted" : "Volume " + Math.round(video.volume * 100) + "%");
});
video.addEventListener("volumechange", applyVolumeUI);
/* ---------------- timeline ---------------- */
function renderProgress() {
var d = DUR || video.duration || 0;
var pct = d ? (video.currentTime / d) * 100 : 0;
played.style.width = pct + "%";
thumb.style.left = pct + "%";
curEl.textContent = fmt(video.currentTime);
scrub.setAttribute("aria-valuenow", Math.round(pct));
scrub.setAttribute("aria-valuetext", fmt(video.currentTime) + " of " + fmt(d));
updateActiveChapter();
}
function renderBuffered() {
var d = DUR || video.duration || 0;
if (!d || !video.buffered.length) return;
var end = 0;
for (var i = 0; i < video.buffered.length; i++) {
if (video.buffered.start(i) <= video.currentTime) end = video.buffered.end(i);
}
if (!end) end = video.buffered.end(video.buffered.length - 1);
buffered.style.width = Math.min(100, (end / d) * 100) + "%";
}
video.addEventListener("timeupdate", function () { renderProgress(); renderBuffered(); });
video.addEventListener("progress", renderBuffered);
function seekRatio(r) {
r = Math.min(1, Math.max(0, r));
var d = DUR || video.duration || 0;
if (d) video.currentTime = r * d;
}
function ratioFromEvent(e) {
var rect = scrub.getBoundingClientRect();
var x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
return rect.width ? x / rect.width : 0;
}
function moveBubble(r) {
r = Math.min(1, Math.max(0, r));
bubble.style.left = r * 100 + "%";
var d = DUR || video.duration || 0;
bubble.textContent = fmt(r * d);
}
scrub.addEventListener("mousemove", function (e) {
if (!scrubbing) moveBubble(ratioFromEvent(e));
});
scrub.addEventListener("mousedown", function (e) {
scrubbing = true;
scrub.classList.add("active");
var r = ratioFromEvent(e);
seekRatio(r); moveBubble(r); renderProgress();
});
window.addEventListener("mousemove", function (e) {
if (!scrubbing) return;
var r = ratioFromEvent(e);
seekRatio(r); moveBubble(r); renderProgress();
});
window.addEventListener("mouseup", function () {
if (scrubbing) { scrubbing = false; scrub.classList.remove("active"); }
});
// touch
scrub.addEventListener("touchstart", function (e) {
scrubbing = true; scrub.classList.add("active");
var r = ratioFromEvent(e); seekRatio(r); moveBubble(r);
}, { passive: true });
scrub.addEventListener("touchmove", function (e) {
if (!scrubbing) return;
var r = ratioFromEvent(e); seekRatio(r); moveBubble(r);
}, { passive: true });
scrub.addEventListener("touchend", function () { scrubbing = false; scrub.classList.remove("active"); });
// keyboard on the slider itself
scrub.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { skip(5); e.preventDefault(); }
else if (e.key === "ArrowLeft") { skip(-5); e.preventDefault(); }
else if (e.key === "Home") { seekRatio(0); e.preventDefault(); }
else if (e.key === "End") { seekRatio(0.999); e.preventDefault(); }
});
/* ---------------- chapters ---------------- */
function buildChapters() {
var d = DUR || video.duration || 0;
markers.innerHTML = "";
chapPanel.innerHTML = "";
railList.innerHTML = "";
CHAPTERS.forEach(function (c, i) {
c.t = c.f * d;
// marker
var mk = document.createElement("div");
mk.className = "marker";
mk.style.left = c.f * 100 + "%";
markers.appendChild(mk);
// menu item
var li = document.createElement("li");
var mi = document.createElement("button");
mi.className = "menu-item";
mi.setAttribute("role", "menuitem");
mi.dataset.idx = i;
mi.innerHTML = "<span>" + c.name + "</span><span class='mi-time'>" + fmt(c.t) + "</span>";
mi.addEventListener("click", function () { jumpTo(i); closeMenus(); });
li.appendChild(mi);
chapPanel.appendChild(li);
// rail item
var ri = document.createElement("li");
var rb = document.createElement("button");
rb.className = "rail-item";
rb.dataset.idx = i;
rb.innerHTML =
"<span class='rail-num'>" + pad(i + 1) + "</span>" +
"<span class='rail-body'><span class='rail-name'>" + c.name + "</span>" +
"<span class='rail-desc'>" + c.desc + "</span>" +
"<span class='rail-bar'><span></span></span></span>" +
"<span class='rail-time'>" + fmt(c.t) + "</span>";
rb.addEventListener("click", function () { jumpTo(i); });
ri.appendChild(rb);
railList.appendChild(ri);
});
railCount.textContent = CHAPTERS.length + " marks";
}
function jumpTo(i) {
var c = CHAPTERS[i];
if (!c) return;
video.currentTime = c.t + 0.05;
toast("Chapter " + (i + 1) + " · <span class='t-mono'>" + fmt(c.t) + "</span> " + c.name);
if (video.paused) renderProgress();
}
function currentChapterIndex() {
var t = video.currentTime, idx = 0;
for (var i = 0; i < CHAPTERS.length; i++) {
if (t >= CHAPTERS[i].t - 0.01) idx = i;
}
return idx;
}
var lastChap = -1;
function updateActiveChapter() {
var idx = currentChapterIndex();
// rail highlight + progress within chapter
var railItems = railList.querySelectorAll(".rail-item");
railItems.forEach(function (el) {
var on = parseInt(el.dataset.idx, 10) === idx;
el.classList.toggle("active", on);
if (on) {
var start = CHAPTERS[idx].t;
var end = CHAPTERS[idx + 1] ? CHAPTERS[idx + 1].t : (DUR || video.duration || start + 1);
var span = end - start || 1;
var p = Math.min(100, Math.max(0, ((video.currentTime - start) / span) * 100));
var bar = el.querySelector(".rail-bar span");
if (bar) bar.style.width = p + "%";
}
});
// menu active
if (idx !== lastChap) {
chapPanel.querySelectorAll(".menu-item").forEach(function (el) {
el.classList.toggle("active", parseInt(el.dataset.idx, 10) === idx);
});
lastChap = idx;
}
}
/* ---------------- speed menu ---------------- */
function buildSpeeds() {
speedPanel.innerHTML = "";
SPEEDS.forEach(function (s) {
var li = document.createElement("li");
var mi = document.createElement("button");
mi.className = "menu-item" + (s === 1 ? " active" : "");
mi.setAttribute("role", "menuitem");
mi.dataset.speed = s;
mi.innerHTML = "<span class='speed-mono'>" + s.toFixed(2).replace(/0$/, "") + "×</span><span class='mi-check'>✓</span>";
mi.addEventListener("click", function () { setSpeed(s); closeMenus(); });
li.appendChild(mi);
speedPanel.appendChild(li);
});
}
function setSpeed(s) {
video.playbackRate = s;
speedLabel.innerHTML = (s === 1 ? "1.0" : String(s)) + "×";
speedPanel.querySelectorAll(".menu-item").forEach(function (el) {
el.classList.toggle("active", parseFloat(el.dataset.speed) === s);
});
toast("Speed <span class='t-mono'>" + s + "×</span>");
}
/* ---------------- menus open/close ---------------- */
function closeMenus() {
chapMenu.classList.remove("open");
speedMenu.classList.remove("open");
chapBtn.setAttribute("aria-expanded", "false");
speedBtn.setAttribute("aria-expanded", "false");
}
function toggleMenu(menu, btn) {
var open = menu.classList.contains("open");
closeMenus();
if (!open) { menu.classList.add("open"); btn.setAttribute("aria-expanded", "true"); showUI(); }
}
chapBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleMenu(chapMenu, chapBtn); });
speedBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleMenu(speedMenu, speedBtn); });
document.addEventListener("click", function (e) {
if (!chapMenu.contains(e.target) && !speedMenu.contains(e.target)) closeMenus();
});
/* ---------------- fullscreen ---------------- */
fsBtn.addEventListener("click", function () {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (player.requestFullscreen) {
player.requestFullscreen().catch(function () { toast("Fullscreen blocked"); });
} else if (video.webkitEnterFullscreen) {
video.webkitEnterFullscreen();
} else {
toast("Fullscreen not supported");
}
});
document.addEventListener("fullscreenchange", function () {
var fs = !!document.fullscreenElement;
player.classList.toggle("is-fs", fs);
fsBtn.setAttribute("aria-label", fs ? "Exit fullscreen" : "Fullscreen");
});
/* ---------------- picture in picture ---------------- */
pipBtn.addEventListener("click", function () {
if (!document.pictureInPictureEnabled) { toast("PiP not supported"); return; }
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
video.requestPictureInPicture().catch(function () { toast("PiP unavailable"); });
}
});
/* ---------------- keyboard shortcuts (global) ---------------- */
document.addEventListener("keydown", function (e) {
var tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target === scrub && (e.key.indexOf("Arrow") === 0)) return; // slider handles its own
var k = e.key;
if (k === " " || k === "k" || k === "K") { togglePlay(); e.preventDefault(); }
else if (k === "j" || k === "J") { skip(-10); e.preventDefault(); }
else if (k === "l" || k === "L") { skip(10); e.preventDefault(); }
else if (k === "ArrowRight") { skip(5); e.preventDefault(); }
else if (k === "ArrowLeft") { skip(-5); e.preventDefault(); }
else if (k === "ArrowUp") { video.muted = false; video.volume = Math.min(1, video.volume + 0.1); applyVolumeUI(); toast("Volume " + Math.round(video.volume * 100) + "%"); e.preventDefault(); }
else if (k === "ArrowDown") { video.volume = Math.max(0, video.volume - 0.1); video.muted = video.volume === 0; applyVolumeUI(); toast(video.muted ? "Muted" : "Volume " + Math.round(video.volume * 100) + "%"); e.preventDefault(); }
else if (k === "m" || k === "M") { video.muted = !video.muted; applyVolumeUI(); toast(video.muted ? "Muted" : "Unmuted"); e.preventDefault(); }
else if (k === "f" || k === "F") { fsBtn.click(); e.preventDefault(); }
else if (k === "c" || k === "C") { toggleMenu(chapMenu, chapBtn); e.preventDefault(); }
else if (/^[0-9]$/.test(k)) { seekRatio(parseInt(k, 10) / 10); showUI(); e.preventDefault(); }
else if (k === "Escape") { closeMenus(); }
});
/* ---------------- metadata / fallback simulation ---------------- */
function init() {
DUR = video.duration && isFinite(video.duration) ? video.duration : 0;
durEl.textContent = fmt(DUR);
buildChapters();
renderProgress();
renderBuffered();
}
video.addEventListener("loadedmetadata", init);
video.addEventListener("ended", function () {
player.classList.remove("playing", "hide-ui");
toast("Playback complete");
});
buildSpeeds();
applyVolumeUI();
// If the sample video can't load, simulate a 3:20 clip so the UI stays alive.
var simmed = false;
video.addEventListener("error", startSim);
setTimeout(function () {
if (!DUR && (video.readyState === 0)) startSim();
}, 3500);
function startSim() {
if (simmed || DUR) return;
simmed = true;
DUR = 200; // 3:20
var simTime = 0, simPlaying = false, raf = null, last = 0;
durEl.textContent = fmt(DUR);
buildChapters();
Object.defineProperty(video, "duration", { configurable: true, get: function () { return DUR; } });
Object.defineProperty(video, "currentTime", {
configurable: true,
get: function () { return simTime; },
set: function (v) { simTime = Math.min(Math.max(0, v), DUR); renderSim(); }
});
Object.defineProperty(video, "paused", { configurable: true, get: function () { return !simPlaying; } });
Object.defineProperty(video, "buffered", {
configurable: true,
get: function () {
var end = Math.min(DUR, simTime + 28);
return { length: 1, start: function () { return 0; }, end: function () { return end; } };
}
});
video.play = function () {
simPlaying = true;
video.dispatchEvent(new Event("play"));
last = performance.now();
loop();
return Promise.resolve();
};
video.pause = function () {
simPlaying = false;
if (raf) cancelAnimationFrame(raf);
video.dispatchEvent(new Event("pause"));
};
function loop() {
if (!simPlaying) return;
var now = performance.now();
simTime += ((now - last) / 1000) * (video.playbackRate || 1);
last = now;
if (simTime >= DUR) { simTime = DUR; simPlaying = false; renderSim(); video.dispatchEvent(new Event("pause")); video.dispatchEvent(new Event("ended")); return; }
renderSim();
raf = requestAnimationFrame(loop);
}
function renderSim() { renderProgress(); renderBuffered(); }
renderSim();
toast("Demo mode · simulated <span class='t-mono'>3:20</span> reel");
}
// Try to read metadata immediately if already available
if (video.readyState >= 1) init();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cinematic Custom Video 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&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<header class="masthead">
<div class="brand">
<span class="brand-mark" aria-hidden="true">▶</span>
<div>
<p class="brand-kicker">Aperture Cut</p>
<h1 class="brand-title">Custom Player</h1>
</div>
</div>
<div class="masthead-meta">
<span class="chip">4K · HDR</span>
<span class="chip chip-mono" id="fpsChip">24 fps</span>
</div>
</header>
<!-- PLAYER -->
<section class="player" id="player" aria-label="Video player">
<div class="bars bars-top" aria-hidden="true"></div>
<video
id="video"
class="video"
playsinline
preload="metadata"
poster="https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&w=1280&q=80"
>
<source src="https://cdn.jsdelivr.net/gh/mediaelement/mediaelement-files@master/big_buck_bunny.mp4" type="video/mp4" />
</video>
<!-- Center big play -->
<button class="big-play" id="bigPlay" type="button" aria-label="Play video">
<svg viewBox="0 0 24 24" width="34" height="34" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
</button>
<!-- Skip flash indicators -->
<div class="skip-flash skip-left" id="skipLeft" aria-hidden="true">−10s</div>
<div class="skip-flash skip-right" id="skipRight" aria-hidden="true">+10s</div>
<!-- Title overlay -->
<div class="title-overlay">
<span class="rec-dot" aria-hidden="true"></span>
<span class="title-text">Reel 04 — Golden Hour, Take 2</span>
</div>
<div class="bars bars-bottom" aria-hidden="true"></div>
<!-- Controls -->
<div class="controls" id="controls">
<!-- Scrub -->
<div class="scrub" id="scrub" role="slider" tabindex="0"
aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="scrub-buffered" id="buffered"></div>
<div class="scrub-played" id="played"></div>
<div class="scrub-thumb" id="thumb"></div>
<div class="scrub-bubble scrub-bubble-mono" id="bubble">00:00</div>
<div class="scrub-markers" id="markers"></div>
</div>
<div class="control-row">
<div class="ctl-group">
<button class="ctl ctl-play" id="playBtn" type="button" aria-label="Play" aria-pressed="false">
<svg class="ic-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
<svg class="ic-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z" fill="currentColor"/></svg>
</button>
<button class="ctl" id="backBtn" type="button" aria-label="Back 10 seconds">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 7V4L5 9l6 5v-3a6 6 0 1 1-6 6H3a8 8 0 1 0 8-8z" fill="currentColor"/></svg>
</button>
<button class="ctl" id="fwdBtn" type="button" aria-label="Forward 10 seconds">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 7V4l6 5-6 5v-3a6 6 0 1 0 6 6h2a8 8 0 1 1-8-8z" fill="currentColor"/></svg>
</button>
<div class="vol" id="vol">
<button class="ctl" id="muteBtn" type="button" aria-label="Mute" aria-pressed="false">
<svg class="ic-vol" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4zm12.5 3a4 4 0 0 0-2.5-3.7v7.4a4 4 0 0 0 2.5-3.7zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z" fill="currentColor"/></svg>
<svg class="ic-mute" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4zm15.5-.5-1.4-1.4L15.7 9.5 13.3 7.1l-1.4 1.4L14.3 11l-2.4 2.4 1.4 1.4 2.4-2.4 2.4 2.4 1.4-1.4L17.1 11z" fill="currentColor"/></svg>
</button>
<input class="vol-range" id="volRange" type="range" min="0" max="1" step="0.01" value="1" aria-label="Volume" />
</div>
<span class="timecode" id="timecode">
<span id="current">00:00</span>
<span class="tc-sep">/</span>
<span id="duration" class="tc-dim">00:00</span>
</span>
</div>
<div class="ctl-group">
<div class="menu" id="chapMenu">
<button class="ctl ctl-text" id="chapBtn" type="button" aria-haspopup="true" aria-expanded="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v2H4zm0 6h10v2H4zm0 6h16v2H4z" fill="currentColor"/></svg>
<span>Chapters</span>
</button>
<ul class="menu-panel" id="chapPanel" role="menu"></ul>
</div>
<div class="menu" id="speedMenu">
<button class="ctl ctl-text" id="speedBtn" type="button" aria-haspopup="true" aria-expanded="false">
<span class="speed-mono" id="speedLabel">1.0×</span>
</button>
<ul class="menu-panel menu-panel-narrow" id="speedPanel" role="menu"></ul>
</div>
<button class="ctl" id="pipBtn" type="button" aria-label="Picture in picture">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5h18v14H3V5zm2 2v10h14V7H5zm7 4h5v4h-5z" fill="currentColor"/></svg>
</button>
<button class="ctl" id="fsBtn" type="button" aria-label="Fullscreen">
<svg class="ic-fs" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zM4 14h2v4h4v2H4zm14 0h2v6h-6v-2h4z" fill="currentColor"/></svg>
<svg class="ic-fs-exit" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4h2v4H6V6H4V4h4zm10 0h-2v2h-2v2h4V4zM4 14h4v4h-2v-2H4v-2zm12 2v2h2v-2h2v-2h-4v2z" fill="currentColor"/></svg>
</button>
</div>
</div>
</div>
</section>
<!-- Chapter rail -->
<section class="rail" aria-label="Chapter list">
<div class="rail-head">
<h2>Chapters</h2>
<span class="rail-count" id="railCount">6 marks</span>
</div>
<ul class="rail-list" id="railList"></ul>
</section>
<!-- Shortcut legend -->
<section class="legend" aria-label="Keyboard shortcuts">
<span class="legend-title">Shortcuts</span>
<div class="keys">
<kbd>Space</kbd><span>play</span>
<kbd>J</kbd><kbd>L</kbd><span>±10s</span>
<kbd>←</kbd><kbd>→</kbd><span>±5s</span>
<kbd>↑</kbd><kbd>↓</kbd><span>volume</span>
<kbd>M</kbd><span>mute</span>
<kbd>F</kbd><span>full</span>
<kbd>0</kbd>–<kbd>9</kbd><span>seek</span>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A film-forward custom player that wraps a real <video> element in a director’s-cut chrome. The hero sits inside black letterbox bars, and a translucent control bar fades in on hover or focus and auto-hides two seconds into playback. The scrub track paints buffered ranges in muted grey behind an amber progress fill, with a draggable thumb and a live timecode bubble that follows the pointer while you scrub.
Below the transport controls sit the essentials: play/pause, a volume slider that collapses to a mute toggle, a playback-speed menu (0.5x through 2x), a chapter jump menu, and fullscreen. Timecodes render in JetBrains Mono so current-time and duration stay perfectly aligned as digits change. A chapter rail under the frame lists named segments and highlights the one currently on screen.
Every action is keyboard reachable: space or K toggles play, J and L jump ten seconds, arrow keys nudge five seconds and adjust volume, M mutes, F goes fullscreen, and number keys 0-9 seek to that decile of the clip. Focus rings stay visible, controls carry aria labels and pressed state, and the whole layout reflows cleanly down to a 360px phone.