@streamspark/react-video-player
Version:
A fully-featured, YouTube-like video player built completely from scratch using React and TypeScript.
1,032 lines (990 loc) • 33.9 kB
JavaScript
'use strict';
var jsxRuntime = require('react/jsx-runtime');
var react = require('react');
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
var defaultAttributes = {
xmlns: "http://www.w3.org/2000/svg",
width: 24,
height: 24,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round"
};
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().trim();
const createLucideIcon = (iconName, iconNode) => {
const Component = react.forwardRef(
({
color = "currentColor",
size = 24,
strokeWidth = 2,
absoluteStrokeWidth,
className = "",
children,
...rest
}, ref) => {
return react.createElement(
"svg",
{
ref,
...defaultAttributes,
width: size,
height: size,
stroke: color,
strokeWidth: absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth,
className: ["lucide", `lucide-${toKebabCase(iconName)}`, className].join(" "),
...rest
},
[
...iconNode.map(([tag, attrs]) => react.createElement(tag, attrs)),
...Array.isArray(children) ? children : [children]
]
);
}
);
Component.displayName = `${iconName}`;
return Component;
};
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Maximize = createLucideIcon("Maximize", [
["path", { d: "M8 3H5a2 2 0 0 0-2 2v3", key: "1dcmit" }],
["path", { d: "M21 8V5a2 2 0 0 0-2-2h-3", key: "1e4gt3" }],
["path", { d: "M3 16v3a2 2 0 0 0 2 2h3", key: "wsl5sc" }],
["path", { d: "M16 21h3a2 2 0 0 0 2-2v-3", key: "18trek" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Minimize = createLucideIcon("Minimize", [
["path", { d: "M8 3v3a2 2 0 0 1-2 2H3", key: "hohbtr" }],
["path", { d: "M21 8h-3a2 2 0 0 1-2-2V3", key: "5jw1f3" }],
["path", { d: "M3 16h3a2 2 0 0 1 2 2v3", key: "198tvr" }],
["path", { d: "M16 21v-3a2 2 0 0 1 2-2h3", key: "ph8mxp" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Pause = createLucideIcon("Pause", [
["rect", { width: "4", height: "16", x: "6", y: "4", key: "iffhe4" }],
["rect", { width: "4", height: "16", x: "14", y: "4", key: "sjin7j" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Play = createLucideIcon("Play", [
["polygon", { points: "5 3 19 12 5 21 5 3", key: "191637" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Settings = createLucideIcon("Settings", [
[
"path",
{
d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",
key: "1qme2f"
}
],
["circle", { cx: "12", cy: "12", r: "3", key: "1v7zrd" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Type = createLucideIcon("Type", [
["polyline", { points: "4 7 4 4 20 4 20 7", key: "1nosan" }],
["line", { x1: "9", x2: "15", y1: "20", y2: "20", key: "swin9y" }],
["line", { x1: "12", x2: "12", y1: "4", y2: "20", key: "1tx1rr" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Volume1 = createLucideIcon("Volume1", [
["polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5", key: "16drj5" }],
["path", { d: "M15.54 8.46a5 5 0 0 1 0 7.07", key: "ltjumu" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const Volume2 = createLucideIcon("Volume2", [
["polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5", key: "16drj5" }],
["path", { d: "M15.54 8.46a5 5 0 0 1 0 7.07", key: "ltjumu" }],
["path", { d: "M19.07 4.93a10 10 0 0 1 0 14.14", key: "1kegas" }]
]);
/**
* @license lucide-react v0.344.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const VolumeX = createLucideIcon("VolumeX", [
["polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5", key: "16drj5" }],
["line", { x1: "22", x2: "16", y1: "9", y2: "15", key: "1ewh16" }],
["line", { x1: "16", x2: "22", y1: "9", y2: "15", key: "5ykzw1" }]
]);
const SeekBar = ({
currentTime,
duration,
buffered,
onSeek,
formatTime
}) => {
const [isDragging, setIsDragging] = react.useState(false);
const [hoverTime, setHoverTime] = react.useState(0);
const [showPreview, setShowPreview] = react.useState(false);
const seekBarRef = react.useRef(null);
const progress = duration > 0 ? currentTime / duration * 100 : 0;
const handleMouseDown = (e) => {
setIsDragging(true);
handleSeek(e);
};
const handleMouseMove = (e) => {
if (!seekBarRef.current) return;
const rect = seekBarRef.current.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
const time = percentage / 100 * duration;
setHoverTime(time);
setShowPreview(true);
if (isDragging) {
onSeek(time);
}
};
const handleMouseUp = (e) => {
if (isDragging) {
handleSeek(e);
setIsDragging(false);
}
};
const handleMouseLeave = () => {
setShowPreview(false);
setIsDragging(false);
};
const handleSeek = (e) => {
if (!seekBarRef.current) return;
const rect = seekBarRef.current.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
const time = percentage / 100 * duration;
onSeek(time);
};
return /* @__PURE__ */ jsxRuntime.jsxs(
"div",
{
className: "rvp-seek-bar-container",
ref: seekBarRef,
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
onMouseUp: handleMouseUp,
onMouseLeave: handleMouseLeave,
role: "slider",
"aria-label": "Seek",
"aria-valuemin": 0,
"aria-valuemax": duration,
"aria-valuenow": currentTime,
tabIndex: 0,
children: [
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-seek-bar", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-seek-bar-background", children: [
/* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-seek-bar-buffered",
style: { width: `${buffered}%` }
}
),
/* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-seek-bar-progress",
style: { width: `${progress}%` }
}
),
/* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-seek-bar-thumb",
style: { left: `${progress}%` }
}
)
] }) }),
showPreview && /* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-seek-preview",
style: {
left: `${hoverTime / duration * 100}%`,
transform: "translateX(-50%)"
},
children: formatTime(hoverTime)
}
)
]
}
);
};
const Volume = ({
volume,
isMuted,
onVolumeChange,
onMute
}) => {
const [isDragging, setIsDragging] = react.useState(false);
const [showSlider, setShowSlider] = react.useState(false);
const sliderRef = react.useRef(null);
const getVolumeIcon = () => {
if (isMuted || volume === 0) return /* @__PURE__ */ jsxRuntime.jsx(VolumeX, { size: 20 });
if (volume < 0.5) return /* @__PURE__ */ jsxRuntime.jsx(Volume1, { size: 20 });
return /* @__PURE__ */ jsxRuntime.jsx(Volume2, { size: 20 });
};
const handleMouseDown = (e) => {
e.preventDefault();
setIsDragging(true);
handleVolumeChange(e);
};
const handleMouseMove = (e) => {
if (isDragging) {
handleVolumeChange(e);
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleVolumeChange = (e) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, (rect.bottom - e.clientY) / rect.height * 100));
const newVolume = percentage / 100;
onVolumeChange(newVolume);
};
return /* @__PURE__ */ jsxRuntime.jsxs(
"div",
{
className: "rvp-volume-control",
onMouseEnter: () => setShowSlider(true),
onMouseLeave: () => {
if (!isDragging) setShowSlider(false);
},
children: [
/* @__PURE__ */ jsxRuntime.jsx(
"button",
{
className: "rvp-control-btn rvp-volume-btn",
onClick: onMute,
"aria-label": isMuted ? "Unmute" : "Mute",
children: getVolumeIcon()
}
),
/* @__PURE__ */ jsxRuntime.jsx("div", { className: `rvp-volume-slider ${showSlider ? "rvp-show" : ""}`, children: /* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-volume-slider-track",
ref: sliderRef,
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
onMouseUp: handleMouseUp,
role: "slider",
"aria-label": "Volume",
"aria-valuemin": 0,
"aria-valuemax": 100,
"aria-valuenow": Math.round((isMuted ? 0 : volume) * 100),
tabIndex: 0,
children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-volume-slider-background", children: [
/* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-volume-slider-fill",
style: { height: `${isMuted ? 0 : volume * 100}%` }
}
),
/* @__PURE__ */ jsxRuntime.jsx(
"div",
{
className: "rvp-volume-slider-thumb",
style: { bottom: `${isMuted ? 0 : volume * 100}%` }
}
)
] })
}
) })
]
}
);
};
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const PlaybackSpeed = ({
playbackRate,
onPlaybackRateChange
}) => {
const [showMenu, setShowMenu] = react.useState(false);
return /* @__PURE__ */ jsxRuntime.jsxs(
"div",
{
className: "rvp-playback-speed",
onMouseEnter: () => setShowMenu(true),
onMouseLeave: () => setShowMenu(false),
children: [
/* @__PURE__ */ jsxRuntime.jsxs(
"button",
{
className: "rvp-control-btn rvp-speed-btn",
"aria-label": `Playback speed: ${playbackRate}x`,
children: [
/* @__PURE__ */ jsxRuntime.jsx(Settings, { size: 20 }),
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "rvp-speed-label", children: [
playbackRate,
"x"
] })
]
}
),
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: `rvp-speed-menu ${showMenu ? "rvp-show" : ""}`, children: [
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-speed-menu-title", children: "Playback Speed" }),
PLAYBACK_RATES.map((rate) => /* @__PURE__ */ jsxRuntime.jsxs(
"button",
{
className: `rvp-speed-option ${rate === playbackRate ? "rvp-active" : ""}`,
onClick: () => onPlaybackRateChange(rate),
"aria-label": `Set speed to ${rate}x`,
children: [
rate,
"x ",
rate === 1 && "(Normal)"
]
},
rate
))
] })
]
}
);
};
const Controls = ({
isPlaying,
currentTime,
duration,
volume,
isMuted,
playbackRate,
isFullscreen,
captionsEnabled,
buffered,
hasCaptions,
onPlay,
onSeek,
onVolumeChange,
onMute,
onPlaybackRateChange,
onFullscreen,
onToggleCaptions,
formatTime
}) => {
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-video-controls", children: [
/* @__PURE__ */ jsxRuntime.jsx(
SeekBar,
{
currentTime,
duration,
buffered,
onSeek,
formatTime
}
),
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-controls-bar", children: [
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-controls-left", children: [
/* @__PURE__ */ jsxRuntime.jsx(
"button",
{
className: "rvp-control-btn rvp-play-btn",
onClick: onPlay,
"aria-label": isPlaying ? "Pause" : "Play",
children: isPlaying ? /* @__PURE__ */ jsxRuntime.jsx(Pause, { size: 24 }) : /* @__PURE__ */ jsxRuntime.jsx(Play, { size: 24 })
}
),
/* @__PURE__ */ jsxRuntime.jsx(
Volume,
{
volume,
isMuted,
onVolumeChange,
onMute
}
),
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-time-display", children: /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
formatTime(currentTime),
" / ",
formatTime(duration)
] }) })
] }),
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-controls-right", children: [
hasCaptions && /* @__PURE__ */ jsxRuntime.jsx(
"button",
{
className: `rvp-control-btn ${captionsEnabled ? "rvp-active" : ""}`,
onClick: onToggleCaptions,
title: "Toggle Captions",
"aria-label": "Toggle Captions",
children: /* @__PURE__ */ jsxRuntime.jsx(Type, { size: 20 })
}
),
/* @__PURE__ */ jsxRuntime.jsx(
PlaybackSpeed,
{
playbackRate,
onPlaybackRateChange
}
),
/* @__PURE__ */ jsxRuntime.jsx(
"button",
{
className: "rvp-control-btn",
onClick: onFullscreen,
"aria-label": isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen",
children: isFullscreen ? /* @__PURE__ */ jsxRuntime.jsx(Minimize, { size: 20 }) : /* @__PURE__ */ jsxRuntime.jsx(Maximize, { size: 20 })
}
)
] })
] })
] });
};
const LoadingSpinner = () => {
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-video-loading", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-loading-spinner", "aria-label": "Loading video" }) });
};
const useVideo = (videoRef, containerRef, options) => {
const [isPlaying, setIsPlaying] = react.useState(false);
const [currentTime, setCurrentTime] = react.useState(0);
const [duration, setDuration] = react.useState(0);
const [volume, setVolume] = react.useState(1);
const [isMuted, setIsMuted] = react.useState(false);
const [playbackRate, setPlaybackRate] = react.useState(1);
const [isFullscreen, setIsFullscreen] = react.useState(false);
const [captionsEnabled, setCaptionsEnabled] = react.useState(false);
const [isLoading, setIsLoading] = react.useState(true);
const [buffered, setBuffered] = react.useState(0);
const lastVolumeRef = react.useRef(1);
const lastCurrentTimeRef = react.useRef(0);
const togglePlay = react.useCallback(() => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play().catch((error) => {
console.error("Error playing video:", error);
options?.onError?.(error.message);
});
}
options?.onPlayPauseFeedback?.();
}, [isPlaying, videoRef, options]);
const seek = react.useCallback((time) => {
if (!videoRef.current) return;
const clampedTime = Math.max(0, Math.min(duration, time));
const timeDiff = clampedTime - lastCurrentTimeRef.current;
videoRef.current.currentTime = clampedTime;
setCurrentTime(clampedTime);
lastCurrentTimeRef.current = clampedTime;
options?.onSeek?.(clampedTime);
if (Math.abs(timeDiff) >= 5) {
options?.onSeekFeedback?.(timeDiff);
}
}, [videoRef, duration, options]);
const setVolumeValue = react.useCallback((newVolume) => {
if (!videoRef.current) return;
const clampedVolume = Math.max(0, Math.min(1, newVolume));
videoRef.current.volume = clampedVolume;
setVolume(clampedVolume);
if (clampedVolume > 0) {
lastVolumeRef.current = clampedVolume;
if (isMuted) {
setIsMuted(false);
videoRef.current.muted = false;
}
}
options?.onVolumeChange?.(clampedVolume);
options?.onVolumeFeedback?.(clampedVolume, isMuted && clampedVolume === 0);
}, [videoRef, isMuted, options]);
const toggleMute = react.useCallback(() => {
if (!videoRef.current) return;
const newMutedState = !isMuted;
setIsMuted(newMutedState);
videoRef.current.muted = newMutedState;
if (newMutedState) {
lastVolumeRef.current = volume;
} else {
const restoreVolume = lastVolumeRef.current || 0.5;
videoRef.current.volume = restoreVolume;
setVolume(restoreVolume);
}
options?.onVolumeFeedback?.(newMutedState ? 0 : volume, newMutedState);
}, [isMuted, videoRef, volume, options]);
const setPlaybackRateValue = react.useCallback((rate) => {
if (!videoRef.current) return;
const clampedRate = Math.max(0.25, Math.min(2, rate));
videoRef.current.playbackRate = clampedRate;
setPlaybackRate(clampedRate);
}, [videoRef]);
const toggleFullscreen = react.useCallback(() => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen().catch((error) => {
console.error("Error entering fullscreen:", error);
options?.onError?.(error.message);
});
} else {
document.exitFullscreen().catch((error) => {
console.error("Error exiting fullscreen:", error);
options?.onError?.(error.message);
});
}
}, [containerRef, options]);
const toggleCaptions = react.useCallback(() => {
if (!videoRef.current) return;
const textTracks = videoRef.current.textTracks;
if (textTracks.length > 0) {
const track = textTracks[0];
track.mode = captionsEnabled ? "hidden" : "showing";
setCaptionsEnabled(!captionsEnabled);
}
}, [captionsEnabled, videoRef]);
const formatTime = react.useCallback((time) => {
if (isNaN(time)) return "0:00";
const hours = Math.floor(time / 3600);
const minutes = Math.floor(time % 3600 / 60);
const seconds = Math.floor(time % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}, []);
react.useEffect(() => {
const handleKeyDown = (e) => {
if (!videoRef.current || !containerRef.current) return;
const isPlayerFocused = containerRef.current.contains(document.activeElement) || document.activeElement === containerRef.current || document.activeElement === videoRef.current;
if (!isPlayerFocused) return;
const handledKeys = [
"Space",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"KeyM",
"KeyF",
"KeyK",
"KeyJ",
"KeyL",
"Comma",
"Period",
"Home",
"End"
];
if (handledKeys.includes(e.code)) {
e.preventDefault();
}
const currentVideoTime = videoRef.current.currentTime;
switch (e.code) {
case "Space":
case "KeyK":
togglePlay();
break;
case "ArrowLeft":
case "KeyJ":
const newTimeLeft = Math.max(0, currentVideoTime - 10);
seek(newTimeLeft);
break;
case "ArrowRight":
case "KeyL":
const newTimeRight = Math.min(duration, currentVideoTime + 10);
seek(newTimeRight);
break;
case "ArrowUp":
const newVolumeUp = Math.min(1, volume + 0.1);
setVolumeValue(newVolumeUp);
break;
case "ArrowDown":
const newVolumeDown = Math.max(0, volume - 0.1);
setVolumeValue(newVolumeDown);
break;
case "KeyM":
toggleMute();
break;
case "KeyF":
toggleFullscreen();
break;
case "Comma":
if (e.shiftKey) {
setPlaybackRateValue(Math.max(0.25, playbackRate - 0.25));
}
break;
case "Period":
if (e.shiftKey) {
setPlaybackRateValue(Math.min(2, playbackRate + 0.25));
}
break;
case "Home":
seek(0);
break;
case "End":
seek(duration);
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [
togglePlay,
seek,
currentTime,
duration,
setVolumeValue,
volume,
toggleMute,
toggleFullscreen,
setPlaybackRateValue,
playbackRate,
videoRef,
containerRef
]);
react.useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
setIsPlaying(true);
setIsLoading(false);
options?.onPlay?.();
};
const handlePause = () => {
setIsPlaying(false);
options?.onPause?.();
};
const handleTimeUpdate = () => {
const currentTime2 = video.currentTime;
setCurrentTime(currentTime2);
lastCurrentTimeRef.current = currentTime2;
options?.onTimeUpdate?.(currentTime2);
};
const handleDurationChange = () => {
setDuration(video.duration || 0);
};
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
const handleLoadStart = () => {
setIsLoading(true);
};
const handleCanPlay = () => {
setIsLoading(false);
};
const handleProgress = () => {
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
const duration2 = video.duration;
if (duration2 > 0) {
setBuffered(bufferedEnd / duration2 * 100);
}
}
};
const handleEnded = () => {
setIsPlaying(false);
options?.onEnded?.();
};
const handleError = () => {
setIsLoading(false);
options?.onError?.("Video failed to load");
};
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("durationchange", handleDurationChange);
video.addEventListener("volumechange", handleVolumeChange);
video.addEventListener("loadstart", handleLoadStart);
video.addEventListener("canplay", handleCanPlay);
video.addEventListener("progress", handleProgress);
video.addEventListener("ended", handleEnded);
video.addEventListener("error", handleError);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("durationchange", handleDurationChange);
video.removeEventListener("volumechange", handleVolumeChange);
video.removeEventListener("loadstart", handleLoadStart);
video.removeEventListener("canplay", handleCanPlay);
video.removeEventListener("progress", handleProgress);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("error", handleError);
};
}, [videoRef, options]);
react.useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
return {
// State
isPlaying,
currentTime,
duration,
volume,
isMuted,
playbackRate,
isFullscreen,
captionsEnabled,
isLoading,
buffered,
// Controls
togglePlay,
seek,
setVolume: setVolumeValue,
toggleMute,
setPlaybackRate: setPlaybackRateValue,
toggleFullscreen,
toggleCaptions,
formatTime
};
};
const VideoPlayer = ({
src,
poster,
captions,
title,
theme = "dark",
autoplay = false,
loop = false,
muted = false,
controls = true,
width = "100%",
height = "auto",
className = "",
style = {},
onPlay,
onPause,
onTimeUpdate,
onVolumeChange,
onSeek,
onEnded,
onError
}) => {
const videoRef = react.useRef(null);
const containerRef = react.useRef(null);
const controlsTimeoutRef = react.useRef(null);
const [showControls, setShowControls] = react.useState(true);
const [showPlayPause, setShowPlayPause] = react.useState(false);
const [showSeekFeedback, setShowSeekFeedback] = react.useState(false);
const [seekFeedbackText, setSeekFeedbackText] = react.useState("");
const [showVolumeFeedback, setShowVolumeFeedback] = react.useState(false);
const [volumeFeedbackText, setVolumeFeedbackText] = react.useState("");
const videoState = useVideo(videoRef, containerRef, {
onPlay,
onPause,
onTimeUpdate,
onVolumeChange,
onSeek,
onEnded,
onError,
onSeekFeedback: (timeDiff) => {
if (Math.abs(timeDiff) >= 5) {
setSeekFeedbackText(timeDiff > 0 ? `+${Math.round(timeDiff)}s` : `${Math.round(timeDiff)}s`);
setShowSeekFeedback(true);
setTimeout(() => setShowSeekFeedback(false), 1e3);
}
},
onVolumeFeedback: (volume2, isMuted2) => {
setVolumeFeedbackText(isMuted2 ? "Muted" : `${Math.round(volume2 * 100)}%`);
setShowVolumeFeedback(true);
setTimeout(() => setShowVolumeFeedback(false), 1e3);
},
onPlayPauseFeedback: () => {
setShowPlayPause(true);
setTimeout(() => setShowPlayPause(false), 800);
}
});
const {
isPlaying,
currentTime,
duration,
volume,
isMuted,
playbackRate,
isFullscreen,
captionsEnabled,
isLoading,
buffered,
togglePlay,
seek,
setVolume,
toggleMute,
setPlaybackRate,
toggleFullscreen,
toggleCaptions,
formatTime
} = videoState;
const clearControlsTimeout = () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
const showControlsTemporarily = () => {
setShowControls(true);
clearControlsTimeout();
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
}
}, 3e3);
};
const handleMouseMove = () => {
if (!controls) return;
showControlsTemporarily();
};
const handleMouseLeave = () => {
if (!controls) return;
if (isPlaying) {
clearControlsTimeout();
controlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 1e3);
}
};
react.useEffect(() => {
return () => {
clearControlsTimeout();
};
}, []);
react.useEffect(() => {
if (!isPlaying || !controls) setShowControls(true);
}, [isPlaying, controls]);
react.useEffect(() => {
if (videoRef.current && muted) videoRef.current.muted = true;
}, [muted]);
react.useEffect(() => {
containerRef.current?.focus();
}, []);
react.useEffect(() => {
const handleKeyDown = (e) => {
if (e.code === "Space" && document.activeElement === containerRef.current) {
e.preventDefault();
togglePlay();
showControlsTemporarily();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isPlaying]);
return /* @__PURE__ */ jsxRuntime.jsxs(
"div",
{
ref: containerRef,
className: `rvp-video-player rvp-${theme} ${className}`,
style: { width, height, ...style },
onMouseMove: handleMouseMove,
onMouseLeave: handleMouseLeave,
onClick: () => containerRef.current?.focus(),
tabIndex: 0,
role: "application",
"aria-label": title || "Video Player",
children: [
/* @__PURE__ */ jsxRuntime.jsxs(
"video",
{
ref: videoRef,
src,
poster,
autoPlay: autoplay,
loop,
muted,
preload: "metadata",
className: "rvp-video",
"aria-label": title,
onClick: togglePlay,
children: [
captions && /* @__PURE__ */ jsxRuntime.jsx(
"track",
{
kind: "subtitles",
src: captions,
srcLang: "en",
label: "English",
default: captionsEnabled
}
),
"Your browser does not support the video tag."
]
}
),
isLoading && /* @__PURE__ */ jsxRuntime.jsx(LoadingSpinner, {}),
showPlayPause && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-feedback-overlay rvp-play-pause-feedback", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-feedback-icon", children: isPlaying ? /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "80", height: "80", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M6 4h4v16H6V4zm8 0h4v16h-4V4z" }) }) : /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "80", height: "80", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 5v14l11-7z" }) }) }) }),
showSeekFeedback && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-feedback-overlay rvp-seek-feedback", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-feedback-text", children: seekFeedbackText }) }),
showVolumeFeedback && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-feedback-overlay rvp-volume-feedback", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "rvp-feedback-text", children: [
/* @__PURE__ */ jsxRuntime.jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", style: { marginRight: "8px" }, children: volumeFeedbackText === "Muted" ? /* @__PURE__ */ jsxRuntime.jsx("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" }) : /* @__PURE__ */ jsxRuntime.jsx("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" }) }),
volumeFeedbackText
] }) }),
controls && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `rvp-video-overlay ${showControls ? "rvp-show" : ""}`, children: [
title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rvp-video-title", children: /* @__PURE__ */ jsxRuntime.jsx("h3", { children: title }) }),
/* @__PURE__ */ jsxRuntime.jsx(
Controls,
{
isPlaying,
currentTime,
duration,
volume,
isMuted,
playbackRate,
isFullscreen,
captionsEnabled,
buffered,
hasCaptions: !!captions,
onPlay: togglePlay,
onSeek: seek,
onVolumeChange: setVolume,
onMute: toggleMute,
onPlaybackRateChange: setPlaybackRate,
onFullscreen: toggleFullscreen,
onToggleCaptions: toggleCaptions,
formatTime
}
)
] })
]
}
);
};
exports.VideoPlayer = VideoPlayer;
exports.useVideo = useVideo;
//# sourceMappingURL=index.js.map