Web InnoventixFreeCode

Media Video Player

Original · free

custom video player controls UI

byWeb InnoventixReact + Tailwind
mediavideoplayer
Open

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
media-video-player.tsx
"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&times; 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}&times;</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

motion

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 quote

Similar components

Browse all →