A custom video player lets you match your brand and control the user experience. Here is a fully featured player with modern controls.
Video Player Hook
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseVideoPlayerOptions {
autoplay?: boolean;
startTime?: number;
}
export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolumeState] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [playbackRate, setPlaybackRateState] = useState(1);
const [buffered, setBuffered] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const video = videoRef.current;
const togglePlay = useCallback(() => {
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
}, [video]);
const seek = useCallback(
(time: number) => {
if (!video) return;
video.currentTime = Math.max(0, Math.min(time, duration));
},
[video, duration]
);
const setVolume = useCallback(
(val: number) => {
if (!video) return;
video.volume = Math.max(0, Math.min(val, 1));
video.muted = val === 0;
},
[video]
);
const toggleMute = useCallback(() => {
if (!video) return;
video.muted = !video.muted;
}, [video]);
const setPlaybackRate = useCallback(
(rate: number) => {
if (!video) return;
video.playbackRate = rate;
},
[video]
);
const toggleFullscreen = useCallback(() => {
const container = video?.parentElement;
if (!container) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
}, [video]);
const skipForward = useCallback(() => seek(currentTime + 10), [seek, currentTime]);
const skipBackward = useCallback(() => seek(currentTime - 10), [seek, currentTime]);
useEffect(() => {
if (!video) return;
const handlers = {
play: () => setIsPlaying(true),
pause: () => setIsPlaying(false),
timeupdate: () => setCurrentTime(video.currentTime),
durationchange: () => setDuration(video.duration),
volumechange: () => {
setVolumeState(video.volume);
setIsMuted(video.muted);
},
ratechange: () => setPlaybackRateState(video.playbackRate),
waiting: () => setIsLoading(true),
canplay: () => setIsLoading(false),
loadedmetadata: () => {
setDuration(video.duration);
setIsLoading(false);
if (options.startTime) video.currentTime = options.startTime;
},
progress: () => {
if (video.buffered.length > 0) {
setBuffered(video.buffered.end(video.buffered.length - 1));
}
},
};
for (const [event, handler] of Object.entries(handlers)) {
video.addEventListener(event, handler);
}
const onFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", onFullscreenChange);
return () => {
for (const [event, handler] of Object.entries(handlers)) {
video.removeEventListener(event, handler);
}
document.removeEventListener("fullscreenchange", onFullscreenChange);
};
}, [video, options.startTime]);
return {
videoRef,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isFullscreen,
playbackRate,
buffered,
isLoading,
togglePlay,
seek,
setVolume,
toggleMute,
setPlaybackRate,
toggleFullscreen,
skipForward,
skipBackward,
};
}
Video Player Component
"use client";
import { useVideoPlayer } from "./useVideoPlayer";
import { useEffect, useRef, useState } from "react";
interface VideoPlayerProps {
src: string;
poster?: string;
title?: string;
}
export function VideoPlayer({ src, poster, title }: VideoPlayerProps) {
const player = useVideoPlayer();
const containerRef = useRef<HTMLDivElement>(null);
const [showControls, setShowControls] = useState(true);
const hideTimeout = useRef<ReturnType<typeof setTimeout>>(null);
// Auto-hide controls
useEffect(() => {
if (!player.isPlaying) {
setShowControls(true);
return;
}
const resetTimer = () => {
setShowControls(true);
if (hideTimeout.current) clearTimeout(hideTimeout.current);
hideTimeout.current = setTimeout(() => setShowControls(false), 3000);
};
const container = containerRef.current;
container?.addEventListener("mousemove", resetTimer);
container?.addEventListener("touchstart", resetTimer);
resetTimer();
return () => {
container?.removeEventListener("mousemove", resetTimer);
container?.removeEventListener("touchstart", resetTimer);
if (hideTimeout.current) clearTimeout(hideTimeout.current);
};
}, [player.isPlaying]);
// Keyboard shortcuts
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return;
switch (e.key) {
case " ":
case "k":
e.preventDefault();
player.togglePlay();
break;
case "ArrowLeft":
e.preventDefault();
player.skipBackward();
break;
case "ArrowRight":
e.preventDefault();
player.skipForward();
break;
case "ArrowUp":
e.preventDefault();
player.setVolume(player.volume + 0.1);
break;
case "ArrowDown":
e.preventDefault();
player.setVolume(player.volume - 0.1);
break;
case "m":
player.toggleMute();
break;
case "f":
player.toggleFullscreen();
break;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [player]);
return (
<div
ref={containerRef}
className="relative group bg-black rounded-lg overflow-hidden"
style={{ cursor: showControls ? "default" : "none" }}
>
<video
ref={player.videoRef}
src={src}
poster={poster}
className="w-full aspect-video"
onClick={player.togglePlay}
playsInline
/>
{/* Loading Spinner */}
{player.isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 border-3 border-white/30 border-t-white rounded-full animate-spin" />
</div>
)}
{/* Play Button Overlay */}
{!player.isPlaying && !player.isLoading && (
<button
onClick={player.togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20"
aria-label="Play video"
>
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 ml-1 text-black" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
{/* Controls */}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
{/* Progress Bar */}
<ProgressBar
currentTime={player.currentTime}
duration={player.duration}
buffered={player.buffered}
onSeek={player.seek}
/>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<button onClick={player.togglePlay} className="text-white" aria-label={player.isPlaying ? "Pause" : "Play"}>
{player.isPlaying ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
)}
</button>
<VolumeControl
volume={player.volume}
isMuted={player.isMuted}
onVolumeChange={player.setVolume}
onToggleMute={player.toggleMute}
/>
<span className="text-white text-xs font-mono">
{formatTime(player.currentTime)} / {formatTime(player.duration)}
</span>
</div>
<div className="flex items-center gap-3">
<SpeedSelector
current={player.playbackRate}
onChange={player.setPlaybackRate}
/>
<button onClick={player.toggleFullscreen} className="text-white" aria-label="Toggle fullscreen">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
</div>
{title && showControls && (
<div className="absolute top-4 left-4 text-white text-sm font-medium">
{title}
</div>
)}
</div>
);
}
function ProgressBar({
currentTime,
duration,
buffered,
onSeek,
}: {
currentTime: number;
duration: number;
buffered: number;
onSeek: (time: number) => void;
}) {
const barRef = useRef<HTMLDivElement>(null);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = barRef.current?.getBoundingClientRect();
if (!rect) return;
const percent = (e.clientX - rect.left) / rect.width;
onSeek(percent * duration);
};
const progress = duration ? (currentTime / duration) * 100 : 0;
const bufferedPercent = duration ? (buffered / duration) * 100 : 0;
return (
<div
ref={barRef}
onClick={handleClick}
className="h-1 bg-white/20 rounded cursor-pointer group/progress hover:h-1.5 transition-all"
>
<div
className="h-full bg-white/30 rounded absolute"
style={{ width: `${bufferedPercent}%` }}
/>
<div
className="h-full bg-white rounded relative"
style={{ width: `${progress}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" />
</div>
</div>
);
}
function VolumeControl({
volume,
isMuted,
onVolumeChange,
onToggleMute,
}: {
volume: number;
isMuted: boolean;
onVolumeChange: (v: number) => void;
onToggleMute: () => void;
}) {
const effectiveVolume = isMuted ? 0 : volume;
return (
<div className="flex items-center gap-1.5 group/vol">
<button onClick={onToggleMute} className="text-white" aria-label="Toggle mute">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
{effectiveVolume === 0 ? (
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
) : effectiveVolume < 0.5 ? (
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z" />
) : (
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
)}
</svg>
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={effectiveVolume}
onChange={(e) => onVolumeChange(Number(e.target.value))}
className="w-0 group-hover/vol:w-20 transition-all accent-white"
aria-label="Volume"
/>
</div>
);
}
function SpeedSelector({ current, onChange }: { current: number; onChange: (r: number) => void }) {
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button onClick={() => setOpen(!open)} className="text-white text-xs font-mono">
{current}x
</button>
{open && (
<div className="absolute bottom-full right-0 mb-2 bg-black/90 rounded p-1 flex flex-col">
{speeds.map((s) => (
<button
key={s}
onClick={() => { onChange(s); setOpen(false); }}
className={`px-3 py-1 text-xs rounded ${
s === current ? "text-white bg-white/20" : "text-white/70 hover:text-white"
}`}
>
{s}x
</button>
))}
</div>
)}
</div>
);
}
function formatTime(seconds: number): string {
if (!isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
Need Custom Media Components?
We build rich interactive components for content-driven websites. Contact us to discuss your media needs.