Zum Inhalt springen
UI Kit
Accent

Videoplayer

Ein Videoplayer mit eigener ruhiger Steuerung, Untertiteln, Tastenkürzeln und einem Vorschaubild, das jede Zeichnung oder jedes Bild sein kann.

Eine von 5 Components in Media

4 Variants

Variants

Vorschaubild

Unberührter Zustand mit Titel, Länge und großem Startknopf. Die Steuerung blendet beim Abspielen aus und kommt bei Bewegung oder Fokus zurück.

Untertitel

Mitten im Film pausiert, Untertitel an. Die Zeilen kommen als Daten oder als WebVTT, der Knopf erscheint nur, wenn es Untertitel gibt.

Schlicht in einer Karte

Eine Zeile Steuerung für kleine Player, hier als Lektion in einem Kurs.

Hochkant-Clip

Ein Clip im Format 9:16 für Reels und Storys, mit schlichter Steuerung und einem Beitrag daneben.

Details

Was Videoplayer mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Media
Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.0.0
Datei
components/ui/video-player.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 19 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle VideoPlayerProps aus components/ui/video-player.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • srcoptional
    string

    Video file. Without it the player runs as a preview: the poster stays and play simulates progress.

  • posteroptional
    React.ReactNode

    Poster as any node, e.g. a drawn scene or an <img>. Shown until the video plays.

  • titleoptional
    string

    Title for the accessible name and the label on the untouched poster

  • durationoptional
    number

    Length in seconds when there is no src (or before the metadata loads). Default 90.

  • initialTimeoptional
    number

    Start position in seconds

  • tracksoptional
    VideoTrack[]

    WebVTT files, rendered by the browser. The captions button appears when tracks or cues exist.

  • cuesoptional
    CaptionCue[]

    Captions as data, rendered by the player itself. Works in preview mode, too.

  • defaultCaptionsoptional
    boolean

    Captions on from the start

  • controlsoptional
    'full' | 'minimal'

    full: scrubber row and all buttons. minimal: one row with play, position, time and full screen.

  • aspectoptional
    'video' | 'wide' | 'square' | 'portrait'

    video 16:9, wide 21:9, square, portrait 9:16

  • mutedoptional
    boolean
  • loopoptional
    boolean
  • animateoptional
    boolean

    Poster settles while the player scrolls in, the play button pulses until the first play. Default true.

Eingebaute Texte

Diese Texte schreibt Videoplayer selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 19 Texte anzeigen
playerEnglisch Video playerDeutsch Videoplayer
playEnglisch PlayDeutsch Abspielen
pauseEnglisch PauseDeutsch Pausieren
replayEnglisch Play againDeutsch Noch einmal abspielen
muteEnglisch MuteDeutsch Ton aus
unmuteEnglisch UnmuteDeutsch Ton an
volumeEnglisch VolumeDeutsch Lautstärke
seekEnglisch PositionDeutsch Position
fullscreenEnglisch Full screenDeutsch Vollbild
exitFullscreenEnglisch Exit full screenDeutsch Vollbild beenden
captionsOnEnglisch Show captionsDeutsch Untertitel einblenden
captionsOffEnglisch Hide captionsDeutsch Untertitel ausblenden
ofEnglisch ofDeutsch von
loadingEnglisch LoadingDeutsch Wird geladen
errorEnglisch The video could not be loaded.Deutsch Das Video ließ sich nicht laden.
minuteEnglisch minuteDeutsch Minute
minutesEnglisch minutesDeutsch Minuten
secondEnglisch secondDeutsch Sekunde
secondsEnglisch secondsDeutsch Sekunden

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Videoplayer reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-label, aria-live, aria-orientation, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, region, slider und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 10 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 757 Zeilen
components/ui/video-player.tsx
'use client';

import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';

const TEXTS = {
  en: {
    player: 'Video player',
    play: 'Play',
    pause: 'Pause',
    replay: 'Play again',
    mute: 'Mute',
    unmute: 'Unmute',
    volume: 'Volume',
    seek: 'Position',
    fullscreen: 'Full screen',
    exitFullscreen: 'Exit full screen',
    captionsOn: 'Show captions',
    captionsOff: 'Hide captions',
    of: 'of',
    loading: 'Loading',
    error: 'The video could not be loaded.',
    minute: 'minute',
    minutes: 'minutes',
    second: 'second',
    seconds: 'seconds',
  },
  de: {
    player: 'Videoplayer',
    play: 'Abspielen',
    pause: 'Pausieren',
    replay: 'Noch einmal abspielen',
    mute: 'Ton aus',
    unmute: 'Ton an',
    volume: 'Lautstärke',
    seek: 'Position',
    fullscreen: 'Vollbild',
    exitFullscreen: 'Vollbild beenden',
    captionsOn: 'Untertitel einblenden',
    captionsOff: 'Untertitel ausblenden',
    of: 'von',
    loading: 'Wird geladen',
    error: 'Das Video ließ sich nicht laden.',
    minute: 'Minute',
    minutes: 'Minuten',
    second: 'Sekunde',
    seconds: 'Sekunden',
  },
};
type Texts = (typeof TEXTS)['en'];

/** 65 -> "1:05", 3725 -> "1:02:05" */
export function formatMediaTime(seconds: number) {
  const s = Math.max(0, Math.floor(seconds || 0));
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const ss = String(s % 60).padStart(2, '0');
  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
}

/** Spoken time for screen readers, "1 minute 5 seconds". Built by hand, Intl.DurationFormat differs between server and browser. */
function spokenTime(seconds: number, t: Texts) {
  const s = Math.max(0, Math.floor(seconds || 0));
  const m = Math.floor(s / 60);
  const r = s % 60;
  const parts: string[] = [];
  if (m) parts.push(`${m} ${m === 1 ? t.minute : t.minutes}`);
  if (r || !m) parts.push(`${r} ${r === 1 ? t.second : t.seconds}`);
  return parts.join(' ');
}

const intlLocale = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));

/* ------------------------------------------------------------------ */
/* Motion pieces                                                       */
/* ------------------------------------------------------------------ */

const CSS = `
@keyframes kitfx-video-pulse{0%{opacity:.55;transform:scale(1)}70%,100%{opacity:0;transform:scale(1.55)}}
.kitfx-video-pulse{animation:kitfx-video-pulse 2.6s cubic-bezier(.2,.7,.2,1) infinite}
.kitfx-video-morph{transition:d .32s var(--ease-kit)}
@media (prefers-reduced-motion:reduce){.kitfx-video-pulse{animation:none;opacity:0}.kitfx-video-morph{transition:none}}
`;

/* Play and pause as two shapes each with the same points, so one morphs into the other */
const SHAPES = {
  play: ['M7 4.5L13.25 8.25L13.25 15.75L7 19.5Z', 'M13.25 8.25L19.5 12L19.5 12L13.25 15.75Z'],
  pause: ['M6 5L10 5L10 19L6 19Z', 'M14 5L18 5L18 19L14 19Z'],
};

/** Play turns into pause and back. Browsers without path transitions switch at once. */
function PlayPause({ playing, size = 18 }: { playing: boolean; size?: number }) {
  const d = playing ? SHAPES.pause : SHAPES.play;
  return (
    <svg aria-hidden width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round">
      <path className="kitfx-video-morph" d={d[0]} />
      <path className="kitfx-video-morph" d={d[1]} />
    </svg>
  );
}

/**
 * Progress of the player on its way into view, 0 when its top meets the
 * bottom of the viewport, 1 once its middle reaches the middle. Written to
 * --kitvp-p, so the poster settles without a render per frame. 1 on the
 * server and with reduced motion.
 */
function useScrollIn<T extends HTMLElement>(enabled = true) {
  const ref = useRef<T>(null);
  useEffect(() => {
    const el = ref.current;
    if (!enabled || !el || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = clamp((window.innerHeight - r.top) / (window.innerHeight / 2 + r.height / 2), 0, 1);
      el.style.setProperty('--kitvp-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [enabled]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* A slider track, used for the scrubber and the volume                */
/* ------------------------------------------------------------------ */

interface TrackProps {
  /** 0 to 1 */
  value: number;
  /** 0 to 1, drawn behind the value */
  buffered?: number;
  onChange: (fraction: number) => void;
  onKey: (e: React.KeyboardEvent<HTMLDivElement>) => void;
  label: string;
  valueNow: number;
  valueMax: number;
  valueText: string;
  /** Text in the bubble above the pointer, e.g. the time under the cursor */
  hoverText?: (fraction: number) => string;
  /** Keep a 44 px hit area on phones */
  tall?: boolean;
  className?: string;
}

function Track({ value, buffered, onChange, onKey, label, valueNow, valueMax, valueText, hoverText, tall, className }: TrackProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [drag, setDrag] = useState(false);
  const [hover, setHover] = useState<number | null>(null);
  // Between updates the fill glides, while dragging it follows the pointer directly
  const glide = drag ? '' : 'transition-transform duration-200 ease-linear motion-reduce:transition-none';
  const fractionAt = (clientX: number) => {
    const r = ref.current!.getBoundingClientRect();
    return r.width ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
  };
  return (
    <div
      ref={ref}
      role="slider"
      tabIndex={0}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={valueMax}
      aria-valuenow={valueNow}
      aria-valuetext={valueText}
      aria-orientation="horizontal"
      onKeyDown={onKey}
      onPointerDown={(e) => {
        if (e.button !== 0) return;
        e.currentTarget.setPointerCapture(e.pointerId);
        setDrag(true);
        onChange(fractionAt(e.clientX));
      }}
      onPointerMove={(e) => {
        if (e.pointerType === 'mouse') setHover(fractionAt(e.clientX));
        if (drag) onChange(fractionAt(e.clientX));
      }}
      onPointerUp={() => setDrag(false)}
      onPointerCancel={() => setDrag(false)}
      onPointerLeave={() => setHover(null)}
      className={cn(
        'group/track relative flex cursor-pointer touch-none items-center rounded-md outline-offset-0',
        tall ? 'h-11 @lg/player:h-6' : 'h-11 sm:h-9',
        className,
      )}
    >
      <span
        data-clip-ok
        className={cn(
          'relative h-1 w-full overflow-hidden rounded-full bg-[color-mix(in_oklch,var(--foreground)_16%,transparent)] transition-[height] duration-150 group-hover/track:h-1.5 motion-reduce:transition-none',
          drag && 'h-1.5',
        )}
      >
        {/* Fills slide in from the left (transform), so they glide between updates and keep their round end */}
        {buffered !== undefined && (
          <span className={cn('absolute inset-0 rounded-full bg-[color-mix(in_oklch,var(--foreground)_24%,transparent)]', glide)} style={{ transform: `translateX(${round((buffered - 1) * 100, 3)}%)` }} />
        )}
        {hover !== null && hoverText && (
          <span className="absolute inset-0 rounded-full bg-[color-mix(in_oklch,var(--foreground)_12%,transparent)]" style={{ transform: `translateX(${round((hover - 1) * 100, 3)}%)` }} />
        )}
        <span className={cn('absolute inset-0 rounded-full bg-primary', glide)} style={{ transform: `translateX(${round((value - 1) * 100, 3)}%)` }} />
      </span>
      <span aria-hidden data-clip-ok className={cn('pointer-events-none absolute inset-0', glide)} style={{ transform: `translateX(${round(value * 100, 3)}%)` }}>
        <span
          className={cn(
            'absolute left-0 top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_2px_var(--background),var(--shadow-sm)] transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none',
            drag ? 'scale-125' : 'scale-100 sm:scale-0 sm:group-hover/track:scale-100 sm:group-focus-visible/track:scale-100',
          )}
        />
      </span>
      {hover !== null && hoverText && (
        <span
          aria-hidden
          className="pointer-events-none absolute bottom-full mb-1.5 -translate-x-1/2 rounded-md border border-border bg-popover px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-popover-foreground shadow-[var(--shadow-md)]"
          style={{ left: `${round(hover * 100)}%` }}
        >
          {hoverText(hover)}
        </span>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Player                                                              */
/* ------------------------------------------------------------------ */

export interface CaptionCue {
  /** Seconds */
  start: number;
  end: number;
  text: string;
}

export interface VideoTrack {
  src: string;
  srcLang: string;
  label: string;
  default?: boolean;
}

export interface VideoPlayerProps {
  /** Video file. Without it the player runs as a preview: the poster stays and play simulates progress. */
  src?: string;
  /** Poster as any node, e.g. a drawn scene or an <img>. Shown until the video plays. */
  poster?: React.ReactNode;
  /** Title for the accessible name and the label on the untouched poster */
  title?: string;
  /** Length in seconds when there is no src (or before the metadata loads). Default 90. */
  duration?: number;
  /** Start position in seconds */
  initialTime?: number;
  /** WebVTT files, rendered by the browser. The captions button appears when tracks or cues exist. */
  tracks?: VideoTrack[];
  /** Captions as data, rendered by the player itself. Works in preview mode, too. */
  cues?: CaptionCue[];
  /** Captions on from the start */
  defaultCaptions?: boolean;
  /** full: scrubber row and all buttons. minimal: one row with play, position, time and full screen. */
  controls?: 'full' | 'minimal';
  /** video 16:9, wide 21:9, square, portrait 9:16 */
  aspect?: 'video' | 'wide' | 'square' | 'portrait';
  muted?: boolean;
  loop?: boolean;
  /** Poster settles while the player scrolls in, the play button pulses until the first play. Default true. */
  animate?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

const ASPECT = { video: 'aspect-video', wide: 'aspect-[21/9]', square: 'aspect-square', portrait: 'aspect-[9/16]' } as const;

/**
 * A video player with its own controls: play, scrubber with buffer, time, volume, captions and full screen.
 * Keyboard: space or k plays, arrows seek and change the volume, m mutes, f goes full screen, c toggles captions.
 *   <VideoPlayer src="/tour.mp4" poster={<img src="/tour.jpg" alt="" />} title="Product tour" />
 */
export function VideoPlayer({
  src,
  poster,
  title,
  duration: durationProp = 90,
  initialTime = 0,
  tracks,
  cues,
  defaultCaptions = false,
  controls = 'full',
  aspect = 'video',
  muted: mutedProp = false,
  loop = false,
  animate = true,
  labels,
  className,
}: VideoPlayerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const id = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);

  const [playing, setPlaying] = useState(false);
  const [started, setStarted] = useState(initialTime > 0);
  const [time, setTime] = useState(initialTime);
  const [duration, setDuration] = useState(durationProp);
  const [bufferedEnd, setBufferedEnd] = useState(0);
  const [volume, setVolume] = useState(0.8);
  const [muted, setMuted] = useState(mutedProp);
  const [captions, setCaptions] = useState(defaultCaptions);
  const [fullscreen, setFullscreen] = useState(false);
  const [waiting, setWaiting] = useState(false);
  const [failed, setFailed] = useState(false);
  const [active, setActive] = useState(true);
  const timeRef = useRef(initialTime);
  const hideTimer = useRef<number | undefined>(undefined);
  const lastPointer = useRef<string>('mouse');
  // The slow push-in on the poster in preview mode, off with reduced motion. Read after mount, the server cannot know.
  const [motionOk, setMotionOk] = useState(false);
  useEffect(() => setMotionOk(!prefersReducedMotion()), []);
  // The poster settles while the player scrolls in: from slightly larger and lower to its place
  const posterRef = useScrollIn<HTMLDivElement>(animate);

  const real = Boolean(src) && !failed;
  const hasCaptions = Boolean(cues?.length || tracks?.length);
  const minimal = controls === 'minimal';

  /* Controls fade out after a moment of stillness while playing */
  const poke = useCallback(() => {
    setActive(true);
    window.clearTimeout(hideTimer.current);
    hideTimer.current = window.setTimeout(() => setActive(false), 2600);
  }, []);
  useEffect(() => () => window.clearTimeout(hideTimer.current), []);
  useEffect(() => {
    if (playing) poke();
  }, [playing, poke]);

  /* Only one kit player plays at a time */
  const pauseRef = useRef<() => void>(() => {});
  useEffect(() => {
    pauseRef.current = pause;
  });
  useEffect(() => {
    const onOther = (e: Event) => {
      if ((e as CustomEvent<string>).detail !== id) pauseRef.current();
    };
    window.addEventListener('kit-media-play', onOther);
    return () => window.removeEventListener('kit-media-play', onOther);
  }, [id]);

  /* Preview mode: a clock instead of a file */
  useEffect(() => {
    if (real || !playing) return;
    let raf = 0;
    let last = performance.now();
    let acc = 0;
    const tick = (now: number) => {
      const dt = Math.min(0.25, (now - last) / 1000);
      last = now;
      acc += dt;
      const next = Math.min(duration, timeRef.current + dt);
      timeRef.current = next;
      if (next >= duration) {
        setTime(next);
        if (loop) {
          timeRef.current = 0;
          setTime(0);
        } else {
          setPlaying(false);
          return;
        }
      } else if (acc > 1 / 30) {
        acc = 0;
        setTime(next);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [real, playing, duration, loop]);

  /* Real media: volume, mute and caption tracks follow the state */
  useEffect(() => {
    const v = videoRef.current;
    if (!v) return;
    v.volume = volume;
    v.muted = muted;
  }, [volume, muted, real]);
  useEffect(() => {
    const v = videoRef.current;
    if (!v) return;
    for (const track of Array.from(v.textTracks)) track.mode = captions ? 'showing' : 'hidden';
  }, [captions, real]);

  /* Full screen state follows the document */
  useEffect(() => {
    const on = () => setFullscreen(document.fullscreenElement === rootRef.current);
    document.addEventListener('fullscreenchange', on);
    return () => document.removeEventListener('fullscreenchange', on);
  }, []);

  function play() {
    window.dispatchEvent(new CustomEvent('kit-media-play', { detail: id }));
    setStarted(true);
    if (timeRef.current >= duration - 0.05) seek(0);
    const v = videoRef.current;
    if (real && v) {
      v.play().catch(() => setPlaying(false));
    } else {
      setPlaying(true);
    }
  }
  function pause() {
    const v = videoRef.current;
    if (real && v) v.pause();
    setPlaying(false);
  }
  const toggle = () => (playing ? pause() : play());
  function seek(to: number) {
    const next = clamp(to, 0, duration);
    timeRef.current = next;
    setTime(next);
    const v = videoRef.current;
    if (real && v && Number.isFinite(v.duration)) v.currentTime = next;
  }
  const changeVolume = (v: number) => {
    const next = clamp(round(v, 2), 0, 1);
    setVolume(next);
    setMuted(next === 0);
  };
  const toggleMute = () => {
    if (muted && volume === 0) setVolume(0.5);
    setMuted(!muted);
  };
  const toggleFullscreen = () => {
    const el = rootRef.current;
    if (!el) return;
    if (document.fullscreenElement) {
      document.exitFullscreen?.().catch(() => {});
    } else if (el.requestFullscreen) {
      el.requestFullscreen().catch(() => {});
    } else {
      // iOS Safari only knows full screen on the video element itself
      (videoRef.current as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null)?.webkitEnterFullscreen?.();
    }
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (e.altKey || e.ctrlKey || e.metaKey) return;
    const target = e.target as HTMLElement;
    const onSlider = target.getAttribute('role') === 'slider';
    const onButton = target.tagName === 'BUTTON';
    switch (e.key) {
      case ' ':
        if (onButton) return;
        toggle();
        break;
      case 'k':
      case 'K':
        toggle();
        break;
      case 'ArrowLeft':
        if (onSlider) return;
        seek(timeRef.current - 5);
        break;
      case 'ArrowRight':
        if (onSlider) return;
        seek(timeRef.current + 5);
        break;
      case 'ArrowUp':
        if (onSlider) return;
        changeVolume((muted ? 0 : volume) + 0.1);
        break;
      case 'ArrowDown':
        if (onSlider) return;
        changeVolume((muted ? 0 : volume) - 0.1);
        break;
      case 'm':
      case 'M':
        toggleMute();
        break;
      case 'f':
      case 'F':
        toggleFullscreen();
        break;
      case 'c':
      case 'C':
        if (!hasCaptions) return;
        setCaptions((c) => !c);
        break;
      default:
        return;
    }
    e.preventDefault();
    poke();
  };

  const seekKeys = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const step = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5, PageDown: -duration / 10, PageUp: duration / 10 }[e.key];
    if (step !== undefined) seek(timeRef.current + step);
    else if (e.key === 'Home') seek(0);
    else if (e.key === 'End') seek(duration);
    else return;
    e.preventDefault();
    e.stopPropagation();
    poke();
  };
  const volumeKeys = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const step = { ArrowLeft: -0.05, ArrowDown: -0.05, ArrowRight: 0.05, ArrowUp: 0.05, PageDown: -0.2, PageUp: 0.2 }[e.key];
    const current = muted ? 0 : volume;
    if (step !== undefined) changeVolume(current + step);
    else if (e.key === 'Home') changeVolume(0);
    else if (e.key === 'End') changeVolume(1);
    else return;
    e.preventDefault();
    e.stopPropagation();
  };

  const progress = duration ? clamp(time / duration, 0, 1) : 0;
  const buffered = real ? clamp(bufferedEnd / (duration || 1), 0, 1) : started ? clamp(progress + 0.18, 0, 1) : 0;
  const cue = captions && cues ? cues.find((c) => time >= c.start && time < c.end) : undefined;
  const showControls = started && (!playing || active);
  const ended = !playing && started && time >= duration - 0.05;
  const percent = new Intl.NumberFormat(intlLocale(locale), { style: 'percent' });
  const effectiveVolume = muted ? 0 : volume;

  const button = 'relative grid size-11 shrink-0 place-items-center rounded-lg text-[color-mix(in_oklch,var(--foreground)_82%,transparent)] transition-colors duration-150 hover:bg-[color-mix(in_oklch,var(--foreground)_8%,transparent)] hover:text-foreground sm:size-9';

  return (
    <div
      ref={rootRef}
      role="region"
      aria-label={title ? `${t.player}, ${title}` : t.player}
      tabIndex={-1}
      onKeyDown={onKeyDown}
      onPointerMove={(e) => {
        lastPointer.current = e.pointerType;
        if (playing) poke();
      }}
      className={cn(
        'group/player @container/player relative isolate w-full overflow-hidden rounded-xl border border-border bg-surface-2 text-foreground shadow-[var(--highlight),var(--shadow-lg)] outline-none',
        fullscreen ? 'rounded-none border-0' : ASPECT[aspect],
        playing && !active && 'cursor-none',
        className,
      )}
    >
      {/* Picture: poster, or the video once it plays */}
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div data-clip-ok className="absolute inset-0 -z-10 overflow-hidden" aria-hidden={real && started ? undefined : true}>
        {(!real || !started) && poster && (
          <div ref={posterRef} className="absolute inset-0" style={{ transform: 'translate3d(0, calc((1 - var(--kitvp-p, 1)) * 4%), 0) scale(calc(1.1 - var(--kitvp-p, 1) * 0.1))' }}>
            <div
              className="absolute inset-0 transition-transform duration-500 ease-linear motion-reduce:transition-none [&>*]:size-full"
              style={!real && started && motionOk ? { transform: `scale(${round(1 + progress * 0.06, 4)})`, transformOrigin: '50% 45%' } : undefined}
            >
              {poster}
            </div>
          </div>
        )}
        {real && (
          <video
            ref={videoRef}
            src={src}
            playsInline
            preload="metadata"
            loop={loop}
            muted={mutedProp}
            className={cn('absolute inset-0 size-full object-cover', !started && 'opacity-0')}
            onLoadedMetadata={(e) => {
              const d = e.currentTarget.duration;
              if (Number.isFinite(d)) setDuration(d);
              if (initialTime) e.currentTarget.currentTime = initialTime;
            }}
            onTimeUpdate={(e) => {
              timeRef.current = e.currentTarget.currentTime;
              setTime(e.currentTarget.currentTime);
            }}
            onProgress={(e) => {
              const b = e.currentTarget.buffered;
              if (b.length) setBufferedEnd(b.end(b.length - 1));
            }}
            onPlay={() => setPlaying(true)}
            onPause={() => setPlaying(false)}
            onEnded={() => setPlaying(false)}
            onWaiting={() => setWaiting(true)}
            onPlaying={() => setWaiting(false)}
            onCanPlay={() => setWaiting(false)}
            onError={() => {
              setFailed(true);
              setPlaying(false);
            }}
          >
            {tracks?.map((tr) => (
              <track key={tr.src} kind="captions" src={tr.src} srcLang={tr.srcLang} label={tr.label} default={tr.default} />
            ))}
          </video>
        )}
      </div>

      {/* Click surface: play and pause, double click for full screen */}
      <div
        aria-hidden
        className="absolute inset-0"
        onClick={() => {
          if (lastPointer.current === 'touch' && playing && !active) {
            poke();
            return;
          }
          toggle();
        }}
        onDoubleClick={toggleFullscreen}
      />

      {/* Title on the untouched poster */}
      {title && (
        <div
          aria-hidden={started || undefined}
          className={cn(
            'pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-3 bg-gradient-to-b from-[color-mix(in_oklch,var(--background)_70%,transparent)] to-transparent p-4 pb-10 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none @lg/player:p-5 @lg/player:pb-12',
            started && '-translate-y-2 opacity-0',
          )}
        >
          <p className="min-w-0 truncate text-[13px] font-semibold tracking-[-0.01em] @lg/player:text-sm">{title}</p>
          <span className="shrink-0 rounded-md bg-[color-mix(in_oklch,var(--background)_78%,transparent)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-muted-foreground backdrop-blur-md">
            {formatMediaTime(duration)}
          </span>
        </div>
      )}

      {/* Big play button */}
      <button
        type="button"
        onClick={play}
        aria-label={ended ? t.replay : t.play}
        tabIndex={playing ? -1 : 0}
        aria-hidden={playing || undefined}
        className={cn(
          'absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-[color-mix(in_oklch,var(--foreground)_12%,transparent)] bg-[color-mix(in_oklch,var(--background)_70%,transparent)] text-foreground shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl transition-[opacity,transform,background-color] duration-300 ease-[var(--ease-kit)] hover:scale-[1.04] hover:bg-[color-mix(in_oklch,var(--background)_82%,transparent)] motion-reduce:transition-none motion-reduce:hover:scale-100',
          started ? 'size-14 @lg/player:size-16' : 'size-16 @lg/player:size-20',
          'active:scale-95',
          playing && 'pointer-events-none scale-75 opacity-0',
        )}
      >
        {/* A quiet pulse invites the first play, it stops once the video ran */}
        {!started && animate && <span aria-hidden className="kitfx-video-pulse pointer-events-none absolute -inset-px rounded-full border border-[color-mix(in_oklch,var(--foreground)_40%,transparent)]" />}
        {ended ? (
          <Icon name="rotate-ccw" size={24} strokeWidth={2} />
        ) : (
          <Icon name="play" size={started ? 22 : 28} fill="currentColor" strokeWidth={1.2} className="translate-x-[2px]" />
        )}
      </button>

      {/* Buffering and errors with a real file */}
      {real && waiting && playing && (
        <span role="status" aria-label={t.loading} className="pointer-events-none absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 animate-spin rounded-full border-2 border-[color-mix(in_oklch,var(--foreground)_20%,transparent)] border-t-foreground motion-reduce:animate-none" />
      )}
      {failed && (
        <p role="alert" className="absolute inset-x-4 top-4 rounded-lg bg-[color-mix(in_oklch,var(--background)_82%,transparent)] px-3 py-2 text-center text-[13px] text-foreground backdrop-blur-md">
          {t.error}
        </p>
      )}

      {/* Captions drawn by the player */}
      {cue && (
        <div
          className={cn(
            'pointer-events-none absolute inset-x-4 flex justify-center transition-[bottom] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
            showControls ? (minimal ? 'bottom-[4.25rem] @lg/player:bottom-[4.5rem]' : 'bottom-[4.25rem] @lg/player:bottom-[5.75rem]') : 'bottom-5',
          )}
        >
          <p aria-live="off" className="max-w-[40rem] text-balance rounded-md bg-[color-mix(in_oklch,var(--background)_84%,transparent)] px-2.5 py-1 text-center text-[13px] font-medium leading-snug text-foreground backdrop-blur-md @lg/player:text-[17px]">
            {cue.text}
          </p>
        </div>
      )}

      {/* Control bar */}
      <div
        className={cn(
          'absolute inset-x-2 bottom-2 flex flex-wrap items-center rounded-xl border border-[color-mix(in_oklch,var(--foreground)_10%,transparent)] bg-[color-mix(in_oklch,var(--background)_76%,transparent)] px-1 shadow-[var(--highlight),var(--shadow-md)] backdrop-blur-xl transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none @lg/player:inset-x-3 @lg/player:bottom-3',
          minimal ? 'gap-x-0.5' : 'gap-x-0.5 @lg/player:px-2 @lg/player:pb-1',
          showControls
            ? 'translate-y-0 opacity-100'
            : 'pointer-events-none translate-y-2 opacity-0 group-focus-visible/player:pointer-events-auto group-focus-visible/player:translate-y-0 group-focus-visible/player:opacity-100 group-has-[:focus-visible]/player:pointer-events-auto group-has-[:focus-visible]/player:translate-y-0 group-has-[:focus-visible]/player:opacity-100',
        )}
      >
        <button type="button" onClick={toggle} aria-label={playing ? t.pause : ended ? t.replay : t.play} className={cn(button, 'order-1')}>
          <PlayPause playing={playing} size={18} />
        </button>

        <Track
          tall={!minimal}
          value={progress}
          buffered={buffered}
          onChange={(f) => {
            seek(f * duration);
            poke();
          }}
          onKey={seekKeys}
          label={t.seek}
          valueNow={Math.floor(time)}
          valueMax={Math.floor(duration)}
          valueText={`${spokenTime(time, t)} ${t.of} ${spokenTime(duration, t)}`}
          hoverText={(f) => formatMediaTime(f * duration)}
          className={cn('order-2 mx-1.5 min-w-0 flex-1', !minimal && '@lg/player:order-none @lg/player:basis-full')}
        />

        <span className={cn('order-3 shrink-0 px-1 text-[12px] font-medium tabular-nums text-[color-mix(in_oklch,var(--foreground)_82%,transparent)] @lg/player:px-1.5', !minimal && '@lg/player:order-4')}>
          {formatMediaTime(time)}
          <span className="hidden text-subtle-foreground @md/player:inline"> / {formatMediaTime(duration)}</span>
        </span>

        <button type="button" onClick={toggleMute} aria-label={muted || volume === 0 ? t.unmute : t.mute} aria-pressed={muted || volume === 0} className={cn(button, 'order-5', !minimal && 'hidden @lg/player:order-2 @lg/player:grid')}>
          <Icon name={effectiveVolume === 0 ? 'volume-x' : 'volume'} size={18} />
        </button>

        {!minimal && (
          <Track
            value={effectiveVolume}
            onChange={changeVolume}
            onKey={volumeKeys}
            label={t.volume}
            valueNow={Math.round(effectiveVolume * 100)}
            valueMax={100}
            valueText={percent.format(effectiveVolume)}
            className="order-3 hidden w-20 @lg/player:mr-1 @lg/player:flex"
          />
        )}

        {!minimal && <span aria-hidden className="hidden @lg/player:order-5 @lg/player:block @lg/player:flex-1" />}

        {hasCaptions && !minimal && (
          <button type="button" onClick={() => setCaptions((c) => !c)} aria-label={captions ? t.captionsOff : t.captionsOn} aria-pressed={captions} className={cn(button, 'order-4 @lg/player:order-6', captions && 'text-foreground')}>
            <Icon name="captions" size={18} />
            <span aria-hidden className={cn('absolute bottom-1.5 left-1/2 h-0.5 w-3.5 -translate-x-1/2 rounded-full bg-primary transition-opacity sm:bottom-1', captions ? 'opacity-100' : 'opacity-0')} />
          </button>
        )}

        <button type="button" onClick={toggleFullscreen} aria-label={fullscreen ? t.exitFullscreen : t.fullscreen} className={cn(button, 'order-6 @lg/player:order-7')}>
          <Icon name={fullscreen ? 'minimize' : 'maximize'} size={17} />
        </button>
      </div>
    </div>
  );
}

Häufige Fragen

Wie installiere ich Videoplayer?

Mit aiw add @ai-website/video-player. Der Befehl legt die Datei components/ui/video-player.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Videoplayer?

4 Varianten, Vorschaubild, Untertitel, Schlicht in einer Karte und Hochkant-Clip.

Kann ich die Texte von Videoplayer übersetzen?

Ja. Videoplayer bringt 19 eigene Texte auf Deutsch und Englisch mit, etwa „Videoplayer“, „Abspielen“ und „Pausieren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Videoplayer einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Videoplayer mit aiw_components und installieren sie mit aiw_add @ai-website/video-player.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert