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
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle VideoPlayerProps aus components/ui/video-player.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
srcoptionalstringVideo file. Without it the player runs as a preview: the poster stays and play simulates progress.
posteroptionalReact.ReactNodePoster as any node, e.g. a drawn scene or an <img>. Shown until the video plays.
titleoptionalstringTitle for the accessible name and the label on the untouched poster
durationoptionalnumberLength in seconds when there is no src (or before the metadata loads). Default 90.
initialTimeoptionalnumberStart position in seconds
tracksoptionalVideoTrack[]WebVTT files, rendered by the browser. The captions button appears when tracks or cues exist.
cuesoptionalCaptionCue[]Captions as data, rendered by the player itself. Works in preview mode, too.
defaultCaptionsoptionalbooleanCaptions 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
mutedoptionalbooleanloopoptionalbooleananimateoptionalbooleanPoster 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| player | Englisch Video player | Deutsch Videoplayer |
| play | Englisch Play | Deutsch Abspielen |
| pause | Englisch Pause | Deutsch Pausieren |
| replay | Englisch Play again | Deutsch Noch einmal abspielen |
| mute | Englisch Mute | Deutsch Ton aus |
| unmute | Englisch Unmute | Deutsch Ton an |
| volume | Englisch Volume | Deutsch Lautstärke |
| seek | Englisch Position | Deutsch Position |
| fullscreen | Englisch Full screen | Deutsch Vollbild |
| exitFullscreen | Englisch Exit full screen | Deutsch Vollbild beenden |
| captionsOn | Englisch Show captions | Deutsch Untertitel einblenden |
| captionsOff | Englisch Hide captions | Deutsch Untertitel ausblenden |
| of | Englisch of | Deutsch von |
| loading | Englisch Loading | Deutsch Wird geladen |
| error | Englisch The video could not be loaded. | Deutsch Das Video ließ sich nicht laden. |
| minute | Englisch minute | Deutsch Minute |
| minutes | Englisch minutes | Deutsch Minuten |
| second | Englisch second | Deutsch Sekunde |
| seconds | Englisch seconds | Deutsch 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
'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.