Media Video Player
Original · freecustom video player controls UI
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/media-video-player.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Chapter = { id: string; title: string; start: number; frame: string; alt: string };
type Cue = { start: number; end: number; text: string };
const DURATION = 293;
const RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
const CHAPTERS: Chapter[] = [
{
id: "inversion",
title: "Cold open: the inversion",
start: 0,
frame: "/img/gallery/g30.webp",
alt: "Four glass office towers photographed from street level, their upper floors dissolving into flat white fog.",
},
{
id: "deck",
title: "Above the deck",
start: 52,
frame: "/img/gallery/g09.webp",
alt: "The top of a cloud deck lit gold by low sun, seen from an aircraft window with clear blue sky above.",
},
{
id: "corridor",
title: "The dry corridor",
start: 118,
frame: "/img/gallery/g14.webp",
alt: "Bare dead trees standing in pale cracked ground beneath a hazy, colourless sun.",
},
{
id: "reference",
title: "Clean-air country",
start: 191,
frame: "/img/gallery/g21.webp",
alt: "A deep blue fjord running between steep grey cliffs under scattered cumulus cloud.",
},
{
id: "burnoff",
title: "Burn-off",
start: 246,
frame: "/img/gallery/g05.webp",
alt: "Morning sun burning through thick fog above a dark treeline.",
},
];
const CUES: Cue[] = [
{ start: 1.5, end: 7.5, text: "Toronto, 6:14 in the morning. The city has a lid on it." },
{ start: 8, end: 14, text: "Warm air settles on top of cold air, and the cold air stops climbing." },
{ start: 14.5, end: 21, text: "Meteorologists call it an inversion. Everything the street exhales stays at street level." },
{ start: 22, end: 29, text: "The towers are still there. You just can't see where they end." },
{ start: 30, end: 37.5, text: "Our sensor on the 44th floor reads clean. The one at the bus shelter reads four times worse." },
{ start: 38, end: 46, text: "Same block. Ninety metres apart." },
{ start: 53, end: 60, text: "To find the top of it, we had to leave." },
{ start: 61, end: 68, text: "At nine thousand feet the deck ends and the light comes back all at once." },
{ start: 69, end: 77, text: "This is the boundary layer — the part of the atmosphere that has to deal with us." },
{ start: 78, end: 86, text: "Everything we burn gets stirred into a slab about a kilometre thick." },
{ start: 88, end: 96, text: "The rest of the sky, all forty kilometres of it, barely notices." },
{ start: 98, end: 108, text: "Which is either comforting or the entire problem, depending on where you're standing." },
{ start: 119, end: 127, text: "Two hundred kilometres inland, the lid does something else." },
{ start: 128, end: 136, text: "It traps heat as well as smoke, and the ground gives up its water." },
{ start: 137, end: 146, text: "These trees died standing up. Nobody cut them." },
{ start: 148, end: 157, text: "The soil crust here is fifteen years old and about as thick as a fingernail." },
{ start: 159, end: 169, text: "Step on it and you release dust that stays airborne for three days." },
{ start: 171, end: 182, text: "Our field team stopped walking off the track in the second week." },
{ start: 192, end: 200, text: "Then there's Lysefjord, where the reference stations live." },
{ start: 201, end: 210, text: "Air this clean is a measuring stick. You need somewhere with nothing in it." },
{ start: 212, end: 222, text: "Twelve micrograms per cubic metre in the city. Under two here." },
{ start: 224, end: 236, text: "The walls do the work — the fjord acts like a chimney that never stops drawing." },
{ start: 247, end: 256, text: "Back home, the lid breaks the way it always does. Slowly, then not." },
{ start: 258, end: 268, text: "The sun gets through around ten, the ground warms, and the column finally turns over." },
{ start: 270, end: 281, text: "By lunchtime the number at the bus shelter is normal again." },
{ start: 282, end: 291, text: "It will be back tomorrow at 6:14." },
];
const CSS = `
@keyframes mvp-drift {
0% { transform: scale(1.07) translate3d(-1.3%, -0.9%, 0); }
100% { transform: scale(1.15) translate3d(1.3%, 1%, 0); }
}
@keyframes mvp-sheen {
0% { transform: translateX(-140%); }
100% { transform: translateX(140%); }
}
.mvp-frame { transform: scale(1.07); }
.mvp-frame-live { animation: mvp-drift 26s ease-in-out infinite alternate; }
.mvp-sheen { animation: mvp-sheen 2.4s linear infinite; }
.mvp-grain {
background-image: repeating-linear-gradient(0deg, rgba(255,255,255,0.045) 0 1px, rgba(0,0,0,0) 1px 3px);
}
@media (prefers-reduced-motion: reduce) {
.mvp-frame-live, .mvp-sheen { animation: none !important; }
}
`;
function formatTime(total: number): string {
const whole = Math.max(0, Math.floor(total));
const mins = Math.floor(whole / 60);
const secs = whole % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function chapterIndexAt(t: number): number {
let found = 0;
for (let i = 0; i < CHAPTERS.length; i += 1) {
if (t >= CHAPTERS[i].start) found = i;
}
return found;
}
function chapterEnd(index: number): number {
return index + 1 < CHAPTERS.length ? CHAPTERS[index + 1].start : DURATION;
}
const CTL =
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white/85 transition hover:bg-white/15 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950";
function PlayGlyph({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.74-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
</svg>
);
}
function PauseGlyph({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<rect x="6" y="4.5" width="4" height="15" rx="1.2" />
<rect x="14" y="4.5" width="4" height="15" rx="1.2" />
</svg>
);
}
function Back10Glyph() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
<path d="M11.5 5.5V2.8L7.8 5.5l3.7 2.7V5.5a6.7 6.7 0 1 1-6.6 5.6" />
<text x="12" y="17.6" textAnchor="middle" fontSize="7.4" fontWeight="700" fill="currentColor" stroke="none">10</text>
</svg>
);
}
function Fwd10Glyph() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
<path d="M12.5 5.5V2.8l3.7 2.7-3.7 2.7V5.5a6.7 6.7 0 1 0 6.6 5.6" />
<text x="12" y="17.6" textAnchor="middle" fontSize="7.4" fontWeight="700" fill="currentColor" stroke="none">10</text>
</svg>
);
}
function VolumeGlyph({ level }: { level: "mute" | "low" | "high" }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
<path d="M4 9.5h3.2L12 5.4v13.2L7.2 14.5H4a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Z" fill="currentColor" stroke="none" />
{level === "mute" ? (
<path d="m16.5 9.5 4 5m0-5-4 5" />
) : (
<>
<path d="M15.6 9.4a3.6 3.6 0 0 1 0 5.2" />
{level === "high" ? <path d="M18.3 6.9a7.2 7.2 0 0 1 0 10.2" /> : null}
</>
)}
</svg>
);
}
function CaptionsGlyph() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
<rect x="2.5" y="5" width="19" height="14" rx="3" />
<path d="M10 10.2a2.6 2.6 0 1 0 0 3.6M17.5 10.2a2.6 2.6 0 1 0 0 3.6" />
</svg>
);
}
function GearGlyph() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
<circle cx="12" cy="12" r="3.1" />
<path d="M12 2.6v2.2M12 19.2v2.2M21.4 12h-2.2M4.8 12H2.6M18.6 5.4l-1.6 1.6M7 17l-1.6 1.6M18.6 18.6 17 17M7 7 5.4 5.4" />
</svg>
);
}
function ExpandGlyph({ collapsed }: { collapsed: boolean }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5" aria-hidden="true">
{collapsed ? (
<path d="M9 4H4v5M15 4h5v5M15 20h5v-5M9 20H4v-5" />
) : (
<path d="M4 9h5V4M20 9h-5V4M20 15h-5v5M4 15h5v5" />
)}
</svg>
);
}
function CheckGlyph() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5" aria-hidden="true">
<path d="m4.5 12.5 4.6 4.6L19.5 6.8" />
</svg>
);
}
export default function MediaVideoPlayer() {
const headingId = useId();
const menuId = useId();
const reduceRaw = useReducedMotion();
const reduce = reduceRaw ?? false;
const dur = useCallback((d: number) => (reduce ? 0 : d), [reduce]);
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(false);
const [rate, setRate] = useState<number>(1);
const [volume, setVolume] = useState(0.72);
const [muted, setMuted] = useState(false);
const [captionsOn, setCaptionsOn] = useState(true);
const [loop, setLoop] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [scrubbing, setScrubbing] = useState(false);
const [volDragging, setVolDragging] = useState(false);
const [hoverPct, setHoverPct] = useState<number | null>(null);
const [idleHidden, setIdleHidden] = useState(false);
const [activity, setActivity] = useState(0);
const [nudge, setNudge] = useState<{ id: number; dir: -1 | 1 } | null>(null);
const shellRef = useRef<HTMLDivElement | null>(null);
const barRef = useRef<HTMLDivElement | null>(null);
const volRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
// The ref is the authoritative playhead; state mirrors it for rendering.
const timeRef = useRef(0);
const uiVisible = !playing || scrubbing || menuOpen || !idleHidden;
const setClock = useCallback((next: number) => {
const clamped = Math.min(DURATION, Math.max(0, next));
timeRef.current = clamped;
setTime(clamped);
}, []);
const bumpIdle = useCallback(() => {
setIdleHidden(false);
setActivity((n) => n + 1);
}, []);
const seek = useCallback(
(next: number) => {
setClock(next);
},
[setClock],
);
const skip = useCallback(
(delta: number) => {
setClock(timeRef.current + delta);
setNudge({ id: Date.now(), dir: delta < 0 ? -1 : 1 });
},
[setClock],
);
const togglePlay = useCallback(() => {
if (timeRef.current >= DURATION) setClock(0);
bumpIdle();
setPlaying((p) => !p);
}, [setClock, bumpIdle]);
const nudgeVolume = useCallback((delta: number) => {
setMuted(false);
setVolume((v) => Math.min(1, Math.max(0, Number((v + delta).toFixed(3)))));
}, []);
// Playback clock. Advances wall time by the selected rate; parked while scrubbing.
useEffect(() => {
if (!playing || scrubbing) return;
let last = performance.now();
let frame = 0;
const tick = (now: number) => {
const dt = (now - last) / 1000;
last = now;
const next = timeRef.current + dt * rate;
if (next >= DURATION && !loop) {
setClock(DURATION);
setPlaying(false);
return;
}
setClock(next >= DURATION ? next % DURATION : next);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [playing, scrubbing, rate, loop, setClock]);
useEffect(() => {
if (!nudge) return;
const id = window.setTimeout(() => setNudge(null), 640);
return () => window.clearTimeout(id);
}, [nudge]);
// Fade the controls out after a beat of inactivity, but only during playback.
useEffect(() => {
if (!playing || scrubbing || menuOpen) return;
const id = window.setTimeout(() => setIdleHidden(true), 2600);
return () => window.clearTimeout(id);
}, [playing, scrubbing, menuOpen, activity]);
// Fullscreen
const toggleFullscreen = useCallback(() => {
const el = shellRef.current;
if (!el) return;
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => undefined);
} else {
void el.requestFullscreen().catch(() => undefined);
}
}, []);
useEffect(() => {
const sync = () => setFullscreen(document.fullscreenElement === shellRef.current);
document.addEventListener("fullscreenchange", sync);
return () => document.removeEventListener("fullscreenchange", sync);
}, []);
// Settings menu: outside click + open focus
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: PointerEvent) => {
const node = e.target as Node;
if (menuRef.current?.contains(node) || menuBtnRef.current?.contains(node)) return;
setMenuOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [menuOpen]);
useEffect(() => {
if (!menuOpen) return;
const checked = RATES.findIndex((r) => r === rate);
itemRefs.current[checked >= 0 ? checked : 0]?.focus();
}, [menuOpen, rate]);
const activeIndex = useMemo(() => chapterIndexAt(time), [time]);
const activeChapter = CHAPTERS[activeIndex];
const cue = useMemo(() => CUES.find((c) => time >= c.start && time < c.end) ?? null, [time]);
const progress = time / DURATION;
const buffered = Math.min(1, progress + 0.16);
const effVolume = muted ? 0 : volume;
const volLevel: "mute" | "low" | "high" = effVolume === 0 ? "mute" : effVolume < 0.5 ? "low" : "high";
// ---- Scrub bar ----
const pctFromBar = (e: ReactPointerEvent<HTMLDivElement>): number => {
const el = barRef.current;
if (!el) return 0;
const rect = el.getBoundingClientRect();
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
};
const onBarDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.focus();
setScrubbing(true);
const p = pctFromBar(e);
setHoverPct(p);
seek(p * DURATION);
};
const onBarMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const p = pctFromBar(e);
setHoverPct(p);
if (scrubbing) seek(p * DURATION);
};
const onBarUp = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
setScrubbing(false);
};
const onBarKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
let next: number | null = null;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = time - (e.shiftKey ? 30 : 5);
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = time + (e.shiftKey ? 30 : 5);
else if (e.key === "PageDown") next = time - 30;
else if (e.key === "PageUp") next = time + 30;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = DURATION;
if (next === null) return;
e.preventDefault();
e.stopPropagation();
seek(next);
};
// ---- Volume slider ----
const pctFromVol = (e: ReactPointerEvent<HTMLDivElement>): number => {
const el = volRef.current;
if (!el) return 0;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return effVolume;
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
};
const onVolDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.focus();
setVolDragging(true);
setMuted(false);
setVolume(pctFromVol(e));
};
const onVolMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!volDragging) return;
setVolume(pctFromVol(e));
};
const onVolUp = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
setVolDragging(false);
};
const onVolKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
let handled = true;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") nudgeVolume(-0.05);
else if (e.key === "ArrowRight" || e.key === "ArrowUp") nudgeVolume(0.05);
else if (e.key === "PageDown") nudgeVolume(-0.1);
else if (e.key === "PageUp") nudgeVolume(0.1);
else if (e.key === "Home") {
setMuted(false);
setVolume(0);
} else if (e.key === "End") {
setMuted(false);
setVolume(1);
} else handled = false;
if (!handled) return;
e.preventDefault();
e.stopPropagation();
};
// ---- Settings menu keyboard ----
const onMenuKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
menuBtnRef.current?.focus();
return;
}
if (e.key === "Tab") {
setMenuOpen(false);
return;
}
const items = itemRefs.current.filter((n): n is HTMLButtonElement => n !== null);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
let next = -1;
if (e.key === "ArrowDown") next = (current + 1) % items.length;
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
if (next < 0) return;
e.preventDefault();
e.stopPropagation();
items[next]?.focus();
};
// ---- Player-wide shortcuts ----
const onShellKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest("[role='menu']")) return;
const onButton = target.closest("button") !== null;
if (onButton && (e.key === " " || e.key === "Enter" || e.key === "Spacebar")) return;
const key = e.key.toLowerCase();
if (key === " " || key === "spacebar" || key === "k") {
e.preventDefault();
togglePlay();
} else if (key === "j") {
e.preventDefault();
skip(-10);
} else if (key === "l") {
e.preventDefault();
skip(10);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
skip(-5);
} else if (e.key === "ArrowRight") {
e.preventDefault();
skip(5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
nudgeVolume(0.1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
nudgeVolume(-0.1);
} else if (key === "m") {
e.preventDefault();
setMuted((v) => !v);
} else if (key === "c") {
e.preventDefault();
setCaptionsOn((v) => !v);
} else if (key === "f") {
e.preventDefault();
toggleFullscreen();
} else if (e.key === "Home") {
e.preventDefault();
seek(0);
} else if (e.key === "End") {
e.preventDefault();
seek(DURATION);
} else if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
seek((Number(e.key) / 10) * DURATION);
} else {
return;
}
bumpIdle();
};
const hoverTime = hoverPct !== null ? hoverPct * DURATION : 0;
const hoverChapter = CHAPTERS[chapterIndexAt(hoverTime)];
const toggles: Array<{ label: string; hint: string; checked: boolean; onSelect: () => void }> = [
{ label: "Loop playback", hint: "Wrap back to 0:00 at the end", checked: loop, onSelect: () => setLoop((v) => !v) },
{ label: "Subtitles (English)", hint: "Narration cues on the frame", checked: captionsOn, onSelect: () => setCaptionsOn((v) => !v) },
];
return (
<section
aria-labelledby={headingId}
className="relative w-full bg-gradient-to-b from-slate-50 via-white to-slate-100 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-50"
>
<style>{CSS}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-8 max-w-2xl sm:mb-10">
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-700 dark:border-amber-400/25 dark:text-amber-300">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400" />
Rough cut · reel 04
</p>
<h2 id={headingId} className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Ceiling — a short film about the air above cities
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Five locations, one inversion layer. Scrub the timeline, jump between chapters, or drop the speed to
0.5× to read the haze. Every control here is real and keyboard-operable — focus the frame and press
<kbd className="mx-1 rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-xs text-slate-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">Space</kbd>
to start.
</p>
</header>
<div
ref={shellRef}
role="region"
aria-label="Ceiling — video player"
onKeyDown={onShellKey}
onPointerMove={() => {
if (playing) bumpIdle();
}}
onFocusCapture={bumpIdle}
className={`group/player relative overflow-hidden bg-slate-950 ring-1 ring-slate-900/10 dark:ring-white/10 ${
fullscreen ? "flex h-full w-full items-center justify-center rounded-none" : "rounded-2xl shadow-2xl shadow-slate-900/25 dark:shadow-black/60"
}`}
>
<div className={`relative w-full overflow-hidden bg-slate-950 ${fullscreen ? "max-h-full aspect-video" : "aspect-video"}`}>
{/* Frames — cross-faded on the chapter boundary the clock is currently inside */}
{CHAPTERS.map((chapter, i) => (
<div
key={chapter.id}
aria-hidden={i !== activeIndex}
className={`absolute inset-0 transition-opacity duration-700 ease-out ${i === activeIndex ? "opacity-100" : "opacity-0"}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={chapter.frame}
alt={i === activeIndex ? chapter.alt : ""}
loading="lazy"
draggable={false}
className={`mvp-frame h-full w-full object-cover ${i === activeIndex && playing && !reduce ? "mvp-frame-live" : ""}`}
/>
</div>
))}
{/* Film treatment */}
<div className="mvp-grain pointer-events-none absolute inset-0 z-10 opacity-45 mix-blend-overlay" />
<div className="pointer-events-none absolute inset-0 z-10 bg-[radial-gradient(120%_90%_at_50%_45%,rgba(0,0,0,0)_35%,rgba(0,0,0,0.55)_100%)]" />
{/* Click surface — the real play/pause control */}
<button
type="button"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
aria-label={playing ? "Pause" : "Play"}
aria-keyshortcuts="Space K"
className="absolute inset-0 z-20 flex cursor-default items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-300"
>
<AnimatePresence>
{!playing ? (
<motion.span
initial={{ opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.28 }}
transition={{ duration: dur(0.26), ease: "easeOut" }}
className="grid h-[72px] w-[72px] place-items-center rounded-full bg-slate-950/45 text-white ring-1 ring-white/35 backdrop-blur-sm"
>
<PlayGlyph className="ml-1 h-7 w-7" />
</motion.span>
) : null}
</AnimatePresence>
</button>
{/* Skip feedback */}
<AnimatePresence>
{nudge ? (
<motion.div
key={nudge.id}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.1 }}
transition={{ duration: dur(0.2) }}
className={`pointer-events-none absolute top-1/2 z-30 -translate-y-1/2 rounded-full bg-slate-950/65 px-4 py-2 text-sm font-semibold text-white ring-1 ring-white/20 backdrop-blur-sm ${
nudge.dir < 0 ? "left-6 sm:left-10" : "right-6 sm:right-10"
}`}
>
{nudge.dir < 0 ? "−10s" : "+10s"}
</motion.div>
) : null}
</AnimatePresence>
{/* Top HUD */}
<div
className={`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 to-transparent p-3 transition-opacity duration-300 sm:p-4 ${
uiVisible ? "opacity-100" : "opacity-0"
}`}
>
<span className="rounded-md bg-slate-950/45 px-2.5 py-1 text-[11px] font-medium tracking-wide text-white/90 ring-1 ring-white/15 backdrop-blur-sm">
Ch. {activeIndex + 1} · {activeChapter.title}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{rate !== 1 ? (
<span className="rounded-md bg-amber-400 px-2 py-1 text-[11px] font-bold text-slate-950">{rate}×</span>
) : null}
{loop ? (
<span className="rounded-md bg-slate-950/45 px-2 py-1 text-[11px] font-semibold text-white/90 ring-1 ring-white/15 backdrop-blur-sm">
LOOP
</span>
) : null}
</span>
</div>
{/* Captions */}
<AnimatePresence mode="wait">
{captionsOn && cue ? (
<motion.p
key={cue.start}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: dur(0.22), ease: "easeOut" }}
aria-live="off"
className={`pointer-events-none absolute inset-x-0 z-30 mx-auto max-w-[42ch] text-balance px-4 text-center text-sm font-medium leading-snug text-white transition-[bottom] duration-300 [text-shadow:0_2px_10px_rgb(0_0_0_/_0.9)] sm:text-base ${
uiVisible ? "bottom-[4.75rem] sm:bottom-[5.25rem]" : "bottom-6"
}`}
>
{cue.text}
</motion.p>
) : null}
</AnimatePresence>
{/* Control scrim */}
<div
className={`pointer-events-none absolute inset-x-0 bottom-0 z-30 h-36 bg-gradient-to-t from-black/90 via-black/45 to-transparent transition-opacity duration-300 ${
uiVisible ? "opacity-100" : "opacity-0"
}`}
/>
{/* Controls */}
<div
className={`absolute inset-x-0 bottom-0 z-40 px-2.5 pb-2.5 transition-all duration-300 sm:px-4 sm:pb-3 ${
uiVisible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>
{/* Scrub bar */}
<div className="relative pb-1">
<div
ref={barRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={DURATION}
aria-valuenow={Math.round(time)}
aria-valuetext={`${formatTime(time)} of ${formatTime(DURATION)}, chapter ${activeIndex + 1}: ${activeChapter.title}`}
onPointerDown={onBarDown}
onPointerMove={onBarMove}
onPointerUp={onBarUp}
onPointerCancel={onBarUp}
onPointerLeave={() => {
if (!scrubbing) setHoverPct(null);
}}
onKeyDown={onBarKey}
onBlur={() => {
if (!scrubbing) setHoverPct(null);
}}
className="group/scrub relative flex h-6 w-full cursor-pointer touch-none items-center focus:outline-none"
>
<div
className={`relative w-full overflow-hidden rounded-full bg-white/25 transition-all duration-150 group-focus-visible/scrub:h-2.5 group-focus-visible/scrub:ring-2 group-focus-visible/scrub:ring-amber-300 group-focus-visible/scrub:ring-offset-2 group-focus-visible/scrub:ring-offset-slate-950 group-hover/scrub:h-2.5 ${
scrubbing ? "h-2.5" : "h-1.5"
}`}
>
<div className="absolute inset-y-0 left-0 bg-white/30" style={{ width: `${buffered * 100}%` }}>
{playing && !reduce ? (
<span className="mvp-sheen absolute inset-y-0 right-0 w-16 bg-gradient-to-r from-transparent via-white/35 to-transparent" />
) : null}
</div>
<div className="absolute inset-y-0 left-0 rounded-full bg-amber-400" style={{ width: `${progress * 100}%` }} />
{CHAPTERS.slice(1).map((chapter) => (
<span
key={chapter.id}
className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-slate-950/70"
style={{ left: `${(chapter.start / DURATION) * 100}%` }}
/>
))}
</div>
<span
className={`pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400 shadow-lg shadow-black/50 transition-transform duration-150 ${
scrubbing ? "scale-125" : "scale-0 group-hover/scrub:scale-100 group-focus-visible/scrub:scale-100"
}`}
style={{ left: `${progress * 100}%` }}
/>
</div>
{hoverPct !== null ? (
<div
className="pointer-events-none absolute bottom-7 z-10 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-950/90 px-2.5 py-1.5 text-center text-[11px] font-medium text-white ring-1 ring-white/15 backdrop-blur-sm"
style={{ left: `${Math.min(94, Math.max(6, hoverPct * 100))}%` }}
>
<span className="block font-mono tabular-nums text-amber-300">{formatTime(hoverTime)}</span>
<span className="block max-w-[22ch] truncate text-white/70">{hoverChapter.title}</span>
</div>
) : null}
</div>
{/* Button row */}
<div className="flex items-center gap-0.5 sm:gap-1">
<button type="button" onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className={CTL}>
{playing ? <PauseGlyph /> : <PlayGlyph />}
</button>
<button type="button" onClick={() => skip(-10)} aria-label="Rewind 10 seconds" className={`${CTL} hidden sm:inline-flex`}>
<Back10Glyph />
</button>
<button type="button" onClick={() => skip(10)} aria-label="Forward 10 seconds" className={`${CTL} hidden sm:inline-flex`}>
<Fwd10Glyph />
</button>
<div className="group/vol flex items-center">
<button
type="button"
onClick={() => setMuted((v) => !v)}
aria-label={muted ? "Unmute" : "Mute"}
aria-pressed={muted}
className={CTL}
>
<VolumeGlyph level={volLevel} />
</button>
<div
ref={volRef}
role="slider"
tabIndex={0}
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(effVolume * 100)}
aria-valuetext={`${Math.round(effVolume * 100)} percent`}
onPointerDown={onVolDown}
onPointerMove={onVolMove}
onPointerUp={onVolUp}
onPointerCancel={onVolUp}
onKeyDown={onVolKey}
className={`group/volbar relative flex h-6 cursor-pointer touch-none items-center overflow-hidden transition-[width,opacity] duration-200 focus:outline-none group-focus-within/vol:w-20 group-focus-within/vol:opacity-100 group-hover/vol:w-20 group-hover/vol:opacity-100 ${
volDragging ? "w-20 opacity-100" : "w-0 opacity-0"
}`}
>
<div className="h-1.5 w-full rounded-full bg-white/25 group-focus-visible/volbar:ring-2 group-focus-visible/volbar:ring-amber-300 group-focus-visible/volbar:ring-offset-2 group-focus-visible/volbar:ring-offset-slate-950">
<div className="h-full rounded-full bg-white" style={{ width: `${effVolume * 100}%` }} />
</div>
<span
className="pointer-events-none absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white shadow shadow-black/50"
style={{ left: `calc(${effVolume} * (100% - 0.75rem))` }}
/>
</div>
</div>
<p className="ml-1.5 select-none font-mono text-xs tabular-nums text-white/75 sm:ml-2 sm:text-[13px]">
<span className="text-white">{formatTime(time)}</span>
<span className="mx-1 text-white/40">/</span>
{formatTime(DURATION)}
</p>
<div className="ml-auto flex items-center gap-0.5 sm:gap-1">
<button
type="button"
onClick={() => setCaptionsOn((v) => !v)}
aria-label="Subtitles"
aria-pressed={captionsOn}
className={`${CTL} relative ${captionsOn ? "text-white" : "text-white/55"}`}
>
<CaptionsGlyph />
<span
className={`absolute inset-x-2 bottom-1 h-0.5 rounded-full transition-colors ${captionsOn ? "bg-amber-400" : "bg-transparent"}`}
/>
</button>
<div className="relative">
<button
ref={menuBtnRef}
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-label="Playback settings"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-controls={menuOpen ? menuId : undefined}
className={`${CTL} ${menuOpen ? "bg-white/15 text-white" : ""}`}
>
<GearGlyph />
</button>
<AnimatePresence>
{menuOpen ? (
<motion.div
ref={menuRef}
id={menuId}
role="menu"
aria-label="Playback settings"
onKeyDown={onMenuKey}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: dur(0.16), ease: "easeOut" }}
className="absolute bottom-11 right-0 z-50 w-60 origin-bottom-right rounded-xl border border-white/10 bg-slate-950/95 p-1.5 shadow-2xl shadow-black/60 backdrop-blur-md"
>
<div role="group" aria-label="Playback speed">
<p className="px-2 pb-1.5 pt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/40">
Speed
</p>
<div className="grid grid-cols-3 gap-1">
{RATES.map((r, i) => (
<button
key={r}
ref={(node) => {
itemRefs.current[i] = node;
}}
type="button"
role="menuitemradio"
aria-checked={rate === r}
tabIndex={-1}
onClick={() => setRate(r)}
className={`rounded-lg px-2 py-1.5 text-xs font-semibold tabular-nums transition focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 ${
rate === r ? "bg-amber-400 text-slate-950" : "text-white/75 hover:bg-white/10 hover:text-white"
}`}
>
{r === 1 ? "Normal" : `${r}×`}
</button>
))}
</div>
</div>
<div role="separator" className="my-2 h-px bg-white/10" />
<div role="group" aria-label="Options">
{toggles.map((item, i) => (
<button
key={item.label}
ref={(node) => {
itemRefs.current[RATES.length + i] = node;
}}
type="button"
role="menuitemcheckbox"
aria-checked={item.checked}
tabIndex={-1}
onClick={item.onSelect}
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300"
>
<span
aria-hidden="true"
className={`grid h-4 w-4 shrink-0 place-items-center rounded border transition ${
item.checked ? "border-amber-400 bg-amber-400 text-slate-950" : "border-white/30 text-transparent"
}`}
>
<CheckGlyph />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-white">{item.label}</span>
<span className="block truncate text-[11px] text-white/45">{item.hint}</span>
</span>
</button>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
<button
type="button"
onClick={toggleFullscreen}
aria-label={fullscreen ? "Exit full screen" : "Enter full screen"}
aria-pressed={fullscreen}
className={CTL}
>
<ExpandGlyph collapsed={!fullscreen} />
</button>
</div>
</div>
</div>
</div>
</div>
{/* Chapter rail */}
<ol className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-5">
{CHAPTERS.map((chapter, i) => {
const end = chapterEnd(i);
const local = Math.min(1, Math.max(0, (time - chapter.start) / (end - chapter.start)));
const isActive = i === activeIndex;
return (
<li key={chapter.id}>
<button
type="button"
onClick={() => {
seek(chapter.start);
bumpIdle();
setPlaying(true);
}}
aria-current={isActive ? "true" : undefined}
className={`group/ch relative flex h-full w-full flex-col justify-between overflow-hidden rounded-xl border px-3 py-2.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-amber-400 dark:focus-visible:ring-offset-zinc-950 ${
isActive
? "border-amber-500/45 bg-amber-500/10 dark:border-amber-400/35 dark:bg-amber-400/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900/60 dark:hover:border-zinc-700 dark:hover:bg-zinc-900"
}`}
>
<span className="flex items-baseline justify-between gap-2">
<span
className={`font-mono text-[11px] font-semibold tabular-nums ${
isActive ? "text-amber-700 dark:text-amber-300" : "text-slate-400 dark:text-zinc-500"
}`}
>
{String(i + 1).padStart(2, "0")}
</span>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-zinc-500">
{formatTime(chapter.start)}
</span>
</span>
<span className="mt-1.5 block text-[13px] font-medium leading-snug text-slate-800 dark:text-zinc-200">
{chapter.title}
</span>
<span className="mt-2.5 block h-0.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-800">
<span
className="block h-full rounded-full bg-amber-500 dark:bg-amber-400"
style={{ width: `${(isActive ? local : time > chapter.start ? 1 : 0) * 100}%` }}
/>
</span>
</button>
</li>
);
})}
</ol>
<p className="mt-5 text-xs leading-relaxed text-slate-500 dark:text-zinc-500">
<span className="font-semibold text-slate-600 dark:text-zinc-400">Shortcuts</span> — Space or K play/pause · J
and L jump 10s · arrow keys seek 5s and set volume · 0–9 jump to a percentage · M mute · C subtitles · F full
screen. On the seek bar, Shift + arrow moves 30 seconds.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Media Audio Player
Originalaudio player with progress and volume

Media Podcast
Originalpodcast episode player card

Media Playlist
Originalmusic playlist with now-playing

Media Mini Player
Originalcompact sticky mini player

Media Video Card
Originalvideo thumbnail card with play overlay

Media Waveform
Originalaudio waveform scrubber

Media Video Hero
Originalhero with a video-style backdrop

Media Gallery Player
Originalmedia gallery with a lightbox player

Media Live Badge
Originallive stream card with viewer count

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

