A headless video player with shadcn controls for files, adaptive streams, and embeds.
Add the playback engines, shadcn primitives, styles, hook, and component.
Vidstack provides the headless player; hls.js and dash.js stay local.
bun add @vidstack/react@next hls.js dashjs lucide-reactThe visible controls use your existing theme tokens.
bunx --bun shadcn@latest add button dropdown-menu slider tooltipAdd these lines to your global stylesheet.
@import "@vidstack/react/player/styles/base.css";
@import "@vidstack/react/player/styles/default/captions.css";Save as components/ui/video-player/use-video-player.ts.
"use client";
import type { MouseEvent, PointerEvent } from "react";
import { useState } from "react";
import { useMediaRemote, useMediaStore } from "@vidstack/react";
const FALLBACK_DURATION = 0;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function formatMediaTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return "0:00";
const totalSeconds = Math.floor(value);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
type VideoPlayerController = {
media: ReturnType<typeof useMediaStore>;
seekMin: number;
seekMax: number;
seekValue: number;
bufferedPercent: number;
togglePlayback: (event: MouseEvent<HTMLButtonElement>) => void;
seekBy: (seconds: number, event: MouseEvent<HTMLButtonElement>) => void;
previewSeek: (values: number[]) => void;
commitSeek: (values: number[]) => void;
startPointerSeek: (event: PointerEvent<HTMLElement>) => void;
movePointerSeek: (event: PointerEvent<HTMLElement>) => void;
finishPointerSeek: (event: PointerEvent<HTMLElement>) => void;
changeVolume: (values: number[]) => void;
toggleMuted: (event: MouseEvent<HTMLButtonElement>) => void;
toggleFullscreen: (event: MouseEvent<HTMLButtonElement>) => void;
togglePictureInPicture: (event: MouseEvent<HTMLButtonElement>) => void;
changePlaybackRate: (rate: number) => void;
changeQuality: (index: number) => void;
changeAudioTrack: (index: number) => void;
changeTextTrack: (trackId: string | null) => void;
toggleCaptions: (event: MouseEvent<HTMLButtonElement>) => void;
toggleLoop: () => void;
seekToLiveEdge: () => void;
requestAirPlay: () => void;
requestGoogleCast: () => void;
handleMenuOpenChange: (open: boolean) => void;
};
export function useVideoPlayer(): VideoPlayerController {
const remote = useMediaRemote();
const media = useMediaStore();
const [pendingSeek, setPendingSeek] = useState<number | null>(null);
const [preferredCaptionId, setPreferredCaptionId] = useState<string | null>(null);
const seekMin = media.live && media.isLiveDVR ? media.seekableStart : 0;
const seekMax = Number.isFinite(media.duration)
? media.duration
: Number.isFinite(media.seekableEnd)
? media.seekableEnd
: FALLBACK_DURATION;
const seekValue = clamp(pendingSeek ?? media.currentTime, seekMin, Math.max(seekMin, seekMax));
const seekWindow = Math.max(seekMax - seekMin, 0);
const bufferedPercent =
seekWindow > 0 ? clamp(((media.bufferedEnd - seekMin) / seekWindow) * 100, 0, 100) : 0;
function togglePlayback(event: MouseEvent<HTMLButtonElement>): void {
remote.togglePaused(event.nativeEvent);
}
function seekBy(seconds: number, event: MouseEvent<HTMLButtonElement>): void {
remote.seek(clamp(media.currentTime + seconds, seekMin, seekMax), event.nativeEvent);
}
function previewSeek(values: number[]): void {
const nextTime = values[0] ?? seekMin;
setPendingSeek(nextTime);
remote.seeking(nextTime);
}
function commitSeek(values: number[]): void {
remote.seek(values[0] ?? seekMin);
setPendingSeek(null);
}
function seekFromPointerPosition(event: PointerEvent<HTMLElement>): void {
const bounds = event.currentTarget.getBoundingClientRect();
if (bounds.width <= 0 || seekMax <= seekMin) return;
const progress = clamp((event.clientX - bounds.left) / bounds.width, 0, 1);
remote.seek(seekMin + progress * (seekMax - seekMin), event.nativeEvent);
}
function startPointerSeek(event: PointerEvent<HTMLElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
seekFromPointerPosition(event);
}
function movePointerSeek(event: PointerEvent<HTMLElement>): void {
if (event.currentTarget.hasPointerCapture(event.pointerId)) seekFromPointerPosition(event);
}
function finishPointerSeek(event: PointerEvent<HTMLElement>): void {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
seekFromPointerPosition(event);
event.currentTarget.releasePointerCapture(event.pointerId);
}
function changeVolume(values: number[]): void {
const nextVolume = clamp((values[0] ?? 0) / 100, 0, 1);
if (nextVolume > 0 && media.muted) remote.unmute();
remote.changeVolume(nextVolume);
}
function toggleMuted(event: MouseEvent<HTMLButtonElement>): void {
remote.toggleMuted(event.nativeEvent);
}
function toggleFullscreen(event: MouseEvent<HTMLButtonElement>): void {
remote.toggleFullscreen("prefer-media", event.nativeEvent);
}
function togglePictureInPicture(event: MouseEvent<HTMLButtonElement>): void {
remote.togglePictureInPicture(event.nativeEvent);
}
function changePlaybackRate(rate: number): void {
remote.changePlaybackRate(rate);
}
function changeQuality(index: number): void {
if (index < 0) {
remote.requestAutoQuality();
return;
}
remote.changeQuality(index);
}
function changeAudioTrack(index: number): void {
remote.changeAudioTrack(index);
}
function changeTextTrack(trackId: string | null): void {
if (trackId === null) {
if (media.textTrack) setPreferredCaptionId(media.textTrack.id);
remote.disableCaptions();
return;
}
const trackIndex = media.textTracks.findIndex((track) => track.id === trackId);
if (trackIndex >= 0) {
setPreferredCaptionId(trackId);
remote.changeTextTrackMode(trackIndex, "showing");
}
}
function toggleCaptions(event: MouseEvent<HTMLButtonElement>): void {
if (media.textTrack) {
setPreferredCaptionId(media.textTrack.id);
remote.disableCaptions(event.nativeEvent);
return;
}
const preferredTrackIndex = media.textTracks.findIndex(
(track) => track.id === preferredCaptionId,
);
const fallbackTrackIndex = media.textTracks.findIndex(
(track) => track.kind === "captions" || track.kind === "subtitles",
);
const trackIndex = preferredTrackIndex >= 0 ? preferredTrackIndex : fallbackTrackIndex;
if (trackIndex >= 0) {
setPreferredCaptionId(media.textTracks[trackIndex]?.id ?? null);
remote.changeTextTrackMode(trackIndex, "showing", event.nativeEvent);
}
}
function toggleLoop(): void {
remote.userPrefersLoopChange(!media.loop);
}
function seekToLiveEdge(): void {
remote.seekToLiveEdge();
}
function requestAirPlay(): void {
remote.requestAirPlay();
}
function requestGoogleCast(): void {
remote.requestGoogleCast();
}
function handleMenuOpenChange(open: boolean): void {
if (open) remote.pauseControls();
else remote.resumeControls();
}
return {
media,
seekMin,
seekMax,
seekValue,
bufferedPercent,
togglePlayback,
seekBy,
previewSeek,
commitSeek,
startPointerSeek,
movePointerSeek,
finishPointerSeek,
changeVolume,
toggleMuted,
toggleFullscreen,
togglePictureInPicture,
changePlaybackRate,
changeQuality,
changeAudioTrack,
changeTextTrack,
toggleCaptions,
toggleLoop,
seekToLiveEdge,
requestAirPlay,
requestGoogleCast,
handleMenuOpenChange,
};
}
Save as components/ui/video-player/VideoPlayer.tsx.
"use client";
import type { ComponentProps, ReactNode } from "react";
import {
Captions as CaptionsDisplay,
ChapterTitle,
Controls,
Gesture,
isDASHProvider,
isHLSProvider,
MediaAnnouncer,
MediaPlayer,
MediaProvider,
Poster,
Track,
type MediaPlayerProps,
type MediaProviderAdapter,
type TrackProps,
} from "@vidstack/react";
import {
Airplay,
Captions,
Cast,
Download,
FastForward,
Gauge,
Languages,
LoaderCircle,
Maximize,
Minimize,
Pause,
PictureInPicture2,
Play,
Radio,
Rewind,
RotateCcw,
Settings,
Volume1,
Volume2,
VolumeX,
} from "lucide-react";
import { Button } from "../button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../dropdown-menu";
import { Slider } from "../slider";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
import { cn } from "@/lib/utils";
import { formatMediaTime, useVideoPlayer } from "./use-video-player";
const DEFAULT_PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
export type VideoPlayerTrack = Omit<TrackProps, "id"> & {
id: string;
};
export type VideoPlayerProps = Omit<
MediaPlayerProps,
"children" | "className" | "controls" | "src" | "title"
> & {
src: MediaPlayerProps["src"];
title: string;
tracks?: VideoPlayerTrack[];
playbackRates?: readonly number[];
skipSeconds?: number;
download?: boolean | string;
className?: string;
};
function configureMediaProvider(provider: MediaProviderAdapter | null): void {
if (isHLSProvider(provider)) provider.library = () => import("hls.js");
if (isDASHProvider(provider)) provider.library = () => import("dashjs");
}
function getDownloadHref(
source: VideoPlayerProps["src"],
download: VideoPlayerProps["download"],
): string | null {
if (typeof download === "string") return download;
if (!download) return null;
const firstSource = Array.isArray(source) ? source[0] : source;
if (typeof firstSource === "string") return firstSource;
if (!firstSource || typeof firstSource.src !== "string") return null;
if (firstSource.type === "video/youtube" || firstSource.type === "video/vimeo") return null;
return firstSource.src;
}
function PlayerButton({
label,
children,
className,
...props
}: ComponentProps<typeof Button> & {
label: string;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={label}
className={cn("text-white drop-shadow-sm hover:bg-black/40 hover:text-white", className)}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);
}
function SettingsMenu({
player,
playbackRates,
downloadHref,
}: {
player: ReturnType<typeof useVideoPlayer>;
playbackRates: readonly number[];
downloadHref: string | null;
}) {
const captions = player.media.textTracks.filter(
(track) => track.kind === "captions" || track.kind === "subtitles",
);
return (
<DropdownMenu onOpenChange={player.handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Playback settings"
className="text-white drop-shadow-sm hover:bg-black/40 hover:text-white"
>
<Settings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" sideOffset={8} className="min-w-56">
<DropdownMenuLabel>Playback</DropdownMenuLabel>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="min-h-11">
<Gauge />
Speed
<span className="text-muted-foreground ml-auto font-mono text-xs">
{player.media.playbackRate}×
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-36">
<DropdownMenuRadioGroup value={String(player.media.playbackRate)}>
{playbackRates.map((rate) => (
<DropdownMenuRadioItem
key={rate}
value={String(rate)}
className="min-h-11"
disabled={!player.media.canSetPlaybackRate}
onSelect={() => player.changePlaybackRate(rate)}
>
{rate === 1 ? "Normal" : `${rate}×`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{player.media.qualities.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="min-h-11">
<Radio />
Quality
<span className="text-muted-foreground ml-auto font-mono text-xs">
{player.media.autoQuality
? "Auto"
: player.media.quality
? `${player.media.quality.height}p`
: "—"}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-36">
<DropdownMenuRadioGroup
value={player.media.autoQuality ? "auto" : (player.media.quality?.id ?? "auto")}
>
<DropdownMenuRadioItem
value="auto"
className="min-h-11"
onSelect={() => player.changeQuality(-1)}
>
Auto
</DropdownMenuRadioItem>
{player.media.qualities.map((quality, index) => (
<DropdownMenuRadioItem
key={quality.id}
value={quality.id}
className="min-h-11"
disabled={!player.media.canSetQuality}
onSelect={() => player.changeQuality(index)}
>
{quality.height}p
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{captions.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="min-h-11">
<Languages />
Captions
<span className="text-muted-foreground ml-auto max-w-20 truncate text-xs">
{player.media.textTrack?.label || "Off"}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-40">
<DropdownMenuRadioGroup value={player.media.textTrack?.id ?? "off"}>
<DropdownMenuRadioItem
value="off"
className="min-h-11"
onSelect={() => player.changeTextTrack(null)}
>
Off
</DropdownMenuRadioItem>
{captions.map((track) => (
<DropdownMenuRadioItem
key={track.id}
value={track.id}
className="min-h-11"
onSelect={() => player.changeTextTrack(track.id)}
>
{track.label || track.language || "Captions"}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{player.media.audioTracks.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="min-h-11">
<Volume2 />
Audio
<span className="text-muted-foreground ml-auto max-w-20 truncate text-xs">
{player.media.audioTrack?.label || "Default"}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-40">
<DropdownMenuRadioGroup value={player.media.audioTrack?.id ?? ""}>
{player.media.audioTracks.map((track, index) => (
<DropdownMenuRadioItem
key={track.id}
value={track.id}
className="min-h-11"
onSelect={() => player.changeAudioTrack(index)}
>
{track.label || track.language || `Track ${index + 1}`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuCheckboxItem
checked={player.media.loop}
className="min-h-11"
onCheckedChange={player.toggleLoop}
>
<RotateCcw />
Loop
</DropdownMenuCheckboxItem>
{(player.media.canAirPlay || player.media.canGoogleCast || downloadHref) && (
<DropdownMenuSeparator />
)}
{player.media.canAirPlay && (
<DropdownMenuItem className="min-h-11" onSelect={player.requestAirPlay}>
<Airplay />
AirPlay
</DropdownMenuItem>
)}
{player.media.canGoogleCast && (
<DropdownMenuItem className="min-h-11" onSelect={player.requestGoogleCast}>
<Cast />
Google Cast
</DropdownMenuItem>
)}
{downloadHref && (
<DropdownMenuItem asChild className="min-h-11">
<a href={downloadHref} download>
<Download />
Download
</a>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function Timeline({ player }: { player: ReturnType<typeof useVideoPlayer> }) {
return (
<div className="relative flex h-11 items-center">
<div className="pointer-events-none absolute right-0 left-0 h-1 overflow-hidden rounded-full bg-black/40">
<div
className="h-full origin-left bg-white/50"
style={{ width: `${player.bufferedPercent}%` }}
/>
</div>
<Slider
aria-label="Video progress"
min={player.seekMin}
max={Math.max(player.seekMax, player.seekMin + 0.01)}
step={0.1}
value={[player.seekValue]}
disabled={!player.media.canSeek}
onValueChange={player.previewSeek}
onValueCommit={player.commitSeek}
onPointerDownCapture={player.startPointerSeek}
onPointerMoveCapture={player.movePointerSeek}
onPointerUpCapture={player.finishPointerSeek}
onPointerCancelCapture={player.finishPointerSeek}
className="relative z-10 h-11 cursor-pointer [&_[data-slot=slider-range]]:bg-white [&_[data-slot=slider-thumb]]:border-white [&_[data-slot=slider-thumb]]:bg-white [&_[data-slot=slider-track]]:bg-transparent"
/>
</div>
);
}
function PlayerControls({
player,
playbackRates,
skipSeconds,
downloadHref,
}: {
player: ReturnType<typeof useVideoPlayer>;
playbackRates: readonly number[];
skipSeconds: number;
downloadHref: string | null;
}) {
const volumeLabel = player.media.muted ? "Unmute" : "Mute";
const VolumeIcon = player.media.muted ? VolumeX : player.media.volume < 0.5 ? Volume1 : Volume2;
return (
<TooltipProvider>
<Controls.Root className="pointer-events-none absolute inset-0 z-20 flex flex-col text-white opacity-0 transition-opacity duration-200 data-[visible]:opacity-100 motion-reduce:transition-none">
<Controls.Group className="pointer-events-none flex min-h-11 items-center px-3 drop-shadow-sm in-data-[visible]:pointer-events-auto">
<span className="truncate text-sm font-medium">{player.media.title}</span>
<ChapterTitle className="text-muted-foreground ml-2 hidden truncate text-xs sm:block" />
</Controls.Group>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Button
type="button"
size="icon"
aria-label={player.media.ended ? "Replay" : player.media.paused ? "Play" : "Pause"}
onClick={player.togglePlayback}
className="pointer-events-none size-14 rounded-full border-white/30 bg-black/50 text-white hover:bg-black/70 hover:text-white in-data-[visible]:pointer-events-auto"
>
{player.media.ended ? (
<RotateCcw className="size-6" />
) : player.media.paused ? (
<Play className="size-6 fill-current" />
) : (
<Pause className="size-6 fill-current" />
)}
</Button>
</div>
<Controls.Group className="pointer-events-none mt-auto px-2 pb-1 in-data-[visible]:pointer-events-auto sm:px-3">
<Timeline player={player} />
<div className="flex min-w-0 items-center">
<PlayerButton
label={player.media.ended ? "Replay" : player.media.paused ? "Play" : "Pause"}
onClick={player.togglePlayback}
>
{player.media.ended ? (
<RotateCcw />
) : player.media.paused ? (
<Play className="fill-current" />
) : (
<Pause className="fill-current" />
)}
</PlayerButton>
<PlayerButton
label={`Back ${skipSeconds} seconds`}
onClick={(event) => player.seekBy(-skipSeconds, event)}
className="hidden sm:inline-flex"
disabled={!player.media.canSeek}
>
<Rewind />
</PlayerButton>
<PlayerButton
label={`Forward ${skipSeconds} seconds`}
onClick={(event) => player.seekBy(skipSeconds, event)}
className="hidden sm:inline-flex"
disabled={!player.media.canSeek}
>
<FastForward />
</PlayerButton>
<span className="hidden shrink-0 px-1 font-mono text-xs text-white tabular-nums drop-shadow-sm md:inline">
{formatMediaTime(player.media.currentTime)} / {formatMediaTime(player.media.duration)}
</span>
<div className="hidden items-center sm:flex">
<PlayerButton label={volumeLabel} onClick={player.toggleMuted}>
<VolumeIcon />
</PlayerButton>
<Slider
aria-label="Volume"
min={0}
max={100}
step={1}
value={[player.media.muted ? 0 : player.media.volume * 100]}
disabled={!player.media.canSetVolume}
onValueChange={player.changeVolume}
className="w-20"
/>
</div>
<div className="flex-1" />
{player.media.live && (
<Button
type="button"
variant={player.media.liveEdge ? "secondary" : "ghost"}
size="sm"
onClick={player.seekToLiveEdge}
className="mr-1 h-11 px-2"
>
<span
className={cn(
"size-2 rounded-full",
player.media.liveEdge ? "bg-destructive" : "bg-muted-foreground",
)}
/>
Live
</Button>
)}
{player.media.hasCaptions && (
<PlayerButton
label={player.media.textTrack ? "Turn captions off" : "Turn captions on"}
aria-pressed={Boolean(player.media.textTrack)}
onClick={player.toggleCaptions}
className={cn(
"hidden sm:inline-flex",
player.media.textTrack &&
"bg-white text-black hover:bg-white/90 hover:text-black",
)}
>
<Captions />
</PlayerButton>
)}
<SettingsMenu
player={player}
playbackRates={playbackRates}
downloadHref={downloadHref}
/>
{player.media.canPictureInPicture && (
<PlayerButton
label={
player.media.pictureInPicture ? "Exit picture in picture" : "Picture in picture"
}
onClick={player.togglePictureInPicture}
className="hidden sm:inline-flex"
>
<PictureInPicture2 />
</PlayerButton>
)}
{player.media.canFullscreen && (
<PlayerButton
label={player.media.fullscreen ? "Exit fullscreen" : "Fullscreen"}
onClick={player.toggleFullscreen}
>
{player.media.fullscreen ? <Minimize /> : <Maximize />}
</PlayerButton>
)}
</div>
</Controls.Group>
</Controls.Root>
</TooltipProvider>
);
}
export function VideoPlayer({
src,
title,
tracks = [],
playbackRates = DEFAULT_PLAYBACK_RATES,
skipSeconds = 10,
download = false,
className,
aspectRatio = "16/9",
crossOrigin = true,
load = "visible",
posterLoad = "visible",
playsInline = true,
preload = "metadata",
keyTarget = "player",
keyShortcuts = {
togglePaused: "k Space",
toggleMuted: "m",
toggleFullscreen: "f",
togglePictureInPicture: "i",
toggleCaptions: "c",
seekBackward: "j J ArrowLeft",
seekForward: "l L ArrowRight",
volumeUp: "ArrowUp",
volumeDown: "ArrowDown",
speedUp: ">",
slowDown: "<",
},
onProviderChange,
...playerProps
}: VideoPlayerProps) {
const downloadHref = getDownloadHref(src, download);
function handleProviderChange(
provider: MediaProviderAdapter | null,
nativeEvent: Parameters<NonNullable<MediaPlayerProps["onProviderChange"]>>[1],
): void {
configureMediaProvider(provider);
onProviderChange?.(provider, nativeEvent);
}
return (
<MediaPlayer
src={src}
title={title}
aspectRatio={aspectRatio}
crossOrigin={crossOrigin}
load={load}
posterLoad={posterLoad}
playsInline={playsInline}
preload={preload}
keyTarget={keyTarget}
keyShortcuts={keyShortcuts}
onProviderChange={handleProviderChange}
className={cn(
"group/video-player bg-foreground text-background focus-visible:ring-ring/50 relative isolate block w-full overflow-hidden rounded-lg border outline-none focus-visible:ring-3",
className,
)}
{...playerProps}
>
<MediaProvider>
{playerProps.poster && (
<Poster
src={playerProps.poster}
alt=""
className="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-200 data-[visible]:opacity-100 motion-reduce:transition-none"
/>
)}
{tracks.map((track) => (
<Track key={track.id} {...track} />
))}
</MediaProvider>
<MediaAnnouncer />
<CaptionsDisplay className="vds-captions" />
<Gesture event="pointerup" action="toggle:paused" className="absolute inset-0 z-10" />
<Gesture event="dblpointerup" action="toggle:fullscreen" className="absolute inset-0 z-10" />
<PlayerStateOverlay />
<PlayerControlsShell
playbackRates={playbackRates}
skipSeconds={skipSeconds}
downloadHref={downloadHref}
/>
</MediaPlayer>
);
}
function PlayerStateOverlay() {
const player = useVideoPlayer();
if (player.media.error) {
return (
<div className="bg-background/90 text-foreground absolute inset-0 z-30 flex items-center justify-center p-6 text-center">
<div className="space-y-2">
<p className="font-medium">Unable to play this video</p>
<p className="text-muted-foreground max-w-sm text-sm">
{player.media.error.message || "Check the source URL, media format, and CORS headers."}
</p>
</div>
</div>
);
}
if (player.media.waiting) {
return (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<LoaderCircle
aria-label="Buffering"
className="bg-background/80 text-foreground size-11 animate-spin rounded-full p-2 motion-reduce:animate-none"
/>
</div>
);
}
return null;
}
function PlayerControlsShell({
playbackRates,
skipSeconds,
downloadHref,
}: {
playbackRates: readonly number[];
skipSeconds: number;
downloadHref: string | null;
}) {
const player = useVideoPlayer();
return (
<PlayerControls
player={player}
playbackRates={playbackRates}
skipSeconds={skipSeconds}
downloadHref={downloadHref}
/>
);
}
Pass a title and source. Tracks and every playback option are additive.
"use client";
import { VideoPlayer } from "@/components/ui/video-player/VideoPlayer";
export function Example() {
return (
<VideoPlayer
title="Sprite Fight"
src={{
src: "https://files.vidstack.io/sprite-fight/720p.mp4",
type: "video/mp4",
}}
poster="https://files.vidstack.io/sprite-fight/poster.webp"
tracks={[
{
id: "english",
src: "/captions/english.vtt",
kind: "subtitles",
label: "English",
lang: "en-US",
type: "vtt",
default: true,
},
{
id: "chapters",
src: "/captions/chapters.vtt",
kind: "chapters",
label: "Chapters",
lang: "en-US",
type: "vtt",
},
]}
download
/>
);
}The provider is selected from the source URL or explicit MIME type.
// Direct file or CDN object
const file = { src: "https://cdn.example.com/video.mp4", type: "video/mp4" };
// Adaptive streaming
const hls = { src: "https://cdn.example.com/master.m3u8", type: "application/x-mpegurl" };
const dash = { src: "https://cdn.example.com/manifest.mpd", type: "application/dash+xml" };
// Provider embeds — the provider's policies still apply
const youtube = { src: "https://www.youtube.com/watch?v=VIDEO_ID", type: "video/youtube" };
const vimeo = { src: "https://vimeo.com/VIDEO_ID", type: "video/vimeo" };
// Multiple file qualities
const qualities = [
{ src: "/video-1080.mp4", type: "video/mp4", width: 1920, height: 1080 },
{ src: "/video-720.mp4", type: "video/mp4", width: 1280, height: 720 },
];| Source | Mode | Requirement |
|---|---|---|
| MP4, WebM, audio files | Direct | The browser must support the container and codecs. |
| HLS (.m3u8) | Adaptive and live | Uses native HLS or the locally bundled hls.js engine. |
| MPEG-DASH (.mpd) | Adaptive and live | Uses the locally bundled dash.js engine. |
| YouTube and Vimeo | Embed adapter | Provider branding, policies, and feature restrictions still apply. |
| Blob, File, MediaStream | Object source | Useful for uploads, cameras, screen sharing, and generated media. |
| RTMP, RTSP, SRT | Server conversion | Convert to HLS, DASH, or WebRTC before browser playback. |
Shortcuts are scoped to the most recently active player.
| Keys | Action |
|---|---|
| Space / K | Play or pause |
| J / ← | Seek backward |
| L / → | Seek forward |
| ↑ / ↓ | Raise or lower volume |
| M | Mute or unmute |
| C | Toggle captions |
| F | Toggle fullscreen |
| I | Toggle picture in picture |
| < / > | Lower or raise playback speed |
All remaining Vidstack MediaPlayer props and typed events pass through.
| Prop | Type | Default | Description |
|---|---|---|---|
| src | PlayerSrc | — | One source or an ordered list of file, stream, object, or embed sources. |
| title | string | — | Required accessible title and Media Session title. |
| poster | string | — | Poster image displayed before playback starts. |
| tracks | VideoPlayerTrack[] | [] | Caption, subtitle, chapter, metadata, or description tracks. |
| playbackRates | readonly number[] | 0.5–2× | Rates displayed in the playback settings menu. |
| skipSeconds | number | 10 | Seconds used by the backward and forward buttons. |
| download | boolean | string | false | Shows download for direct sources, or uses an explicit download URL. |
| load | "eager" | "idle" | "visible" | "play" | "custom" | "visible" | Controls when the media provider begins loading. |
| streamType | MediaStreamType | "unknown" | Declare live, low-latency live, or DVR behavior when it cannot be inferred. |
| storage | string | MediaStorage | null | null | Persists volume, captions, time, speed, and quality preferences. |
| className | string | — | Classes applied to the player root. |
type VideoPlayerTrack = Omit<TrackProps, "id"> & {
id: string;
};
type VideoPlayerProps = Omit<
MediaPlayerProps,
"children" | "className" | "controls" | "src" | "title"
> & {
src: MediaPlayerProps["src"];
title: string;
tracks?: VideoPlayerTrack[];
playbackRates?: readonly number[];
skipSeconds?: number;
download?: boolean | string;
className?: string;
};A player can normalize APIs, but it cannot bypass the browser or source owner.
Direct files and adaptive manifests need valid CORS headers, MIME types, byte-range support, and codecs the browser can decode. DRM streams additionally require an EME license flow and are not unlocked by this component.
YouTube and Vimeo run through their official embed providers. Their branding, advertising, availability, and control restrictions remain in effect. RTMP, RTSP, and SRT are ingest protocols and must be converted to a browser delivery format.
Vidstack source documentation