import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from "react"; import { cn } from "@/lib/cn"; import styles from "./VideoPlayer.module.css"; import type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types"; const PLAYBACK_RATES = [0.5, 1, 1.5, 2] as const; const IDLE_HIDE_DELAY = 3000; const SEMANTIC_UPDATE_INTERVAL = 200; const DRAG_UPDATE_INTERVAL = 100; const ACTIVITY_THROTTLE_INTERVAL = 250; type VideoFrameMetadata = { mediaTime: number; }; type VideoWithFrameCallbacks = HTMLVideoElement & { requestVideoFrameCallback?: ( callback: (now: number, metadata: VideoFrameMetadata) => void, ) => number; cancelVideoFrameCallback?: (id: number) => void; }; type PlayerCallbacks = Pick< VideoPlayerProps, | "onPlay" | "onPause" | "onEnded" | "onTimeUpdate" | "onProgress" | "onVolumeChange" >; function formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; } function clampTime(time: number, duration: number): number { if (!Number.isFinite(time) || !Number.isFinite(duration) || duration <= 0) { return 0; } return Math.max(0, Math.min(duration, time)); } function isMobileDevice(): boolean { if (typeof navigator === "undefined") return false; return ( /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent, ) || ("ontouchstart" in window && navigator.maxTouchPoints > 0) ); } export const VideoPlayer = forwardRef( ( { src, poster, autoPlay = false, loop = false, muted: initialMuted = false, preload = "metadata", playbackRate: initialRate = 1, mode = "window", allowFullscreenToggle = true, className, onPlay, onPause, onEnded, onTimeUpdate, onProgress, onVolumeChange, onPlaybackRateChange, onFullscreenChange, }, ref, ) => { const videoRef = useRef(null); const containerRef = useRef(null); const progressRef = useRef(null); const progressBarRef = useRef(null); const progressBufferedRef = useRef(null); const progressThumbRef = useRef(null); const miniProgressBarRef = useRef(null); const idleTimerRef = useRef>(undefined); const isPlayingRef = useRef(false); const showControlsRef = useRef(true); const durationRef = useRef(0); const currentTimeRef = useRef(0); const progressWidthRef = useRef(0); const lastSemanticUpdateRef = useRef(0); const lastDragUpdateRef = useRef(0); const lastActivityRef = useRef(0); const callbacksRef = useRef({}); const dragActiveRef = useRef(false); const dragWasPlayingRef = useRef(false); const dragRectRef = useRef(null); const dragPreviewTimeRef = useRef(0); const dragFrameRef = useRef(null); const activePointerIdRef = useRef(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(initialMuted); const [playbackRate, setPlaybackRateState] = useState(initialRate); const [showControls, setShowControls] = useState(true); const [isDragging, setIsDragging] = useState(false); const [isLoading, setIsLoading] = useState(false); const [hasPlaybackError, setHasPlaybackError] = useState(false); const [showCenterButton, setShowCenterButton] = useState(false); const [isFullscreen, setIsFullscreen] = useState(mode === "fullscreen"); const isMobile = isMobileDevice(); const supportsPointerEvents = typeof window !== "undefined" && "PointerEvent" in window; callbacksRef.current = { onPlay, onPause, onEnded, onTimeUpdate, onProgress, onVolumeChange, }; const applyVisualProgress = useCallback((time: number) => { const safeTime = clampTime(time, durationRef.current); const ratio = durationRef.current > 0 ? safeTime / durationRef.current : 0; const scale = `scaleX(${ratio})`; if (progressBarRef.current) progressBarRef.current.style.transform = scale; if (miniProgressBarRef.current) { miniProgressBarRef.current.style.transform = scale; } if (progressThumbRef.current) { const offset = ratio * progressWidthRef.current; progressThumbRef.current.style.transform = `translate3d(${offset}px, -50%, 0)`; } }, []); const applyBufferedProgress = useCallback((ratio: number) => { const safeRatio = Number.isFinite(ratio) ? Math.max(0, Math.min(1, ratio)) : 0; if (progressBufferedRef.current) { progressBufferedRef.current.style.transform = `scaleX(${safeRatio})`; } }, []); const commitCurrentTime = useCallback((time: number, force = false) => { const safeTime = clampTime(time, durationRef.current); currentTimeRef.current = safeTime; const now = performance.now(); if ( force || now - lastSemanticUpdateRef.current >= SEMANTIC_UPDATE_INTERVAL ) { lastSemanticUpdateRef.current = now; setCurrentTime(safeTime); } return safeTime; }, []); const syncProgress = useCallback( (time: number, forceSemantic = false) => { const safeTime = commitCurrentTime(time, forceSemantic); applyVisualProgress(safeTime); return safeTime; }, [applyVisualProgress, commitCurrentTime], ); const clearIdleTimer = useCallback(() => { if (idleTimerRef.current) { clearTimeout(idleTimerRef.current); idleTimerRef.current = undefined; } }, []); const showPlayerControls = useCallback(() => { showControlsRef.current = true; setShowControls(true); }, []); const resetIdleTimer = useCallback(() => { showPlayerControls(); clearIdleTimer(); idleTimerRef.current = setTimeout(() => { if (isPlayingRef.current && !dragActiveRef.current) { showControlsRef.current = false; setShowControls(false); } }, IDLE_HIDE_DELAY); }, [clearIdleTimer, showPlayerControls]); const handlePointerActivity = useCallback(() => { const now = performance.now(); if ( !showControlsRef.current || now - lastActivityRef.current >= ACTIVITY_THROTTLE_INTERVAL ) { lastActivityRef.current = now; resetIdleTimer(); } }, [resetIdleTimer]); const seekTo = useCallback( (time: number) => { const video = videoRef.current; if (!video) return 0; const safeTime = syncProgress(time, true); video.currentTime = safeTime; return safeTime; }, [syncProgress], ); useImperativeHandle(ref, () => ({ play: () => videoRef.current?.play(), pause: () => videoRef.current?.pause(), seek: (time: number) => { seekTo(time); }, setVolume: (value: number) => { if (videoRef.current) videoRef.current.volume = value; }, setPlaybackRate: (rate: number) => { if (videoRef.current) videoRef.current.playbackRate = rate; }, getCurrentTime: () => videoRef.current?.currentTime ?? 0, getDuration: () => videoRef.current?.duration ?? 0, })); const showManualPlay = useCallback(() => { setIsLoading(false); setShowCenterButton(true); showPlayerControls(); }, [showPlayerControls]); const retryPlayback = useCallback(() => { const video = videoRef.current; if (!video) return; setHasPlaybackError(false); setIsLoading(true); video.load(); video.play().catch(showManualPlay); }, [showManualPlay]); const togglePlay = useCallback(() => { const video = videoRef.current; if (!video) return; if (video.paused) video.play().catch(showManualPlay); else video.pause(); resetIdleTimer(); }, [resetIdleTimer, showManualPlay]); const toggleMute = useCallback(() => { const video = videoRef.current; if (!video) return; video.muted = !video.muted; setIsMuted(video.muted); resetIdleTimer(); }, [resetIdleTimer]); const handleVolumeChange = useCallback( (event: React.ChangeEvent) => { const video = videoRef.current; if (!video) return; const newVolume = Number.parseFloat(event.target.value); video.volume = newVolume; if (newVolume > 0 && video.muted) video.muted = false; setVolumeState(newVolume); setIsMuted(video.muted); resetIdleTimer(); }, [resetIdleTimer], ); const cyclePlaybackRate = useCallback(() => { const video = videoRef.current; if (!video) return; const currentIndex = PLAYBACK_RATES.indexOf( playbackRate as (typeof PLAYBACK_RATES)[number], ); const nextIndex = (currentIndex + 1) % PLAYBACK_RATES.length; const nextRate = PLAYBACK_RATES[nextIndex]; video.playbackRate = nextRate; setPlaybackRateState(nextRate); onPlaybackRateChange?.(nextRate); resetIdleTimer(); }, [playbackRate, onPlaybackRateChange, resetIdleTimer]); const toggleFullscreen = useCallback(() => { const next = !isFullscreen; setIsFullscreen(next); onFullscreenChange?.(next); resetIdleTimer(); }, [isFullscreen, onFullscreenChange, resetIdleTimer]); const getDragTime = useCallback((clientX: number) => { const rect = dragRectRef.current; if (!rect || rect.width <= 0) return 0; const ratio = Math.max( 0, Math.min(1, (clientX - rect.left) / rect.width), ); return ratio * durationRef.current; }, []); const flushDragPreview = useCallback(() => { if (dragFrameRef.current !== null) { cancelAnimationFrame(dragFrameRef.current); dragFrameRef.current = null; } const previewTime = dragPreviewTimeRef.current; applyVisualProgress(previewTime); commitCurrentTime(previewTime, true); }, [applyVisualProgress, commitCurrentTime]); const queueDragPreview = useCallback( (clientX: number, forceSemantic = false) => { if (!dragActiveRef.current) return; dragPreviewTimeRef.current = getDragTime(clientX); if (dragFrameRef.current !== null) return; dragFrameRef.current = requestAnimationFrame((now) => { dragFrameRef.current = null; const previewTime = dragPreviewTimeRef.current; applyVisualProgress(previewTime); if ( forceSemantic || now - lastDragUpdateRef.current >= DRAG_UPDATE_INTERVAL ) { lastDragUpdateRef.current = now; commitCurrentTime(previewTime, true); } }); }, [applyVisualProgress, commitCurrentTime, getDragTime], ); const beginDrag = useCallback( (clientX: number) => { const video = videoRef.current; const progress = progressRef.current; if ( !video || !progress || !Number.isFinite(durationRef.current) || durationRef.current <= 0 ) { return false; } dragRectRef.current = progress.getBoundingClientRect(); dragActiveRef.current = true; dragWasPlayingRef.current = !video.paused; video.pause(); setIsDragging(true); resetIdleTimer(); queueDragPreview(clientX, true); return true; }, [queueDragPreview, resetIdleTimer], ); const finishDrag = useCallback( (clientX?: number) => { if (!dragActiveRef.current) return; if (clientX !== undefined) { dragPreviewTimeRef.current = getDragTime(clientX); } flushDragPreview(); const video = videoRef.current; if (video) video.currentTime = dragPreviewTimeRef.current; dragActiveRef.current = false; dragRectRef.current = null; activePointerIdRef.current = null; setIsDragging(false); if (dragWasPlayingRef.current) video?.play().catch(() => {}); dragWasPlayingRef.current = false; resetIdleTimer(); }, [flushDragPreview, getDragTime, resetIdleTimer], ); const cancelDrag = useCallback(() => { if (!dragActiveRef.current) return; if (dragFrameRef.current !== null) { cancelAnimationFrame(dragFrameRef.current); dragFrameRef.current = null; } const video = videoRef.current; if (video) syncProgress(video.currentTime, true); dragActiveRef.current = false; dragRectRef.current = null; activePointerIdRef.current = null; setIsDragging(false); if (dragWasPlayingRef.current) video?.play().catch(() => {}); dragWasPlayingRef.current = false; resetIdleTimer(); }, [resetIdleTimer, syncProgress]); const handleProgressPointerDown = useCallback( (event: React.PointerEvent) => { if ( !event.isPrimary || (event.pointerType === "mouse" && event.button !== 0) ) { return; } event.preventDefault(); event.stopPropagation(); if (!beginDrag(event.clientX)) return; activePointerIdRef.current = event.pointerId; event.currentTarget.setPointerCapture?.(event.pointerId); }, [beginDrag], ); const handleProgressPointerMove = useCallback( (event: React.PointerEvent) => { if (activePointerIdRef.current !== event.pointerId) return; queueDragPreview(event.clientX); }, [queueDragPreview], ); const handleProgressPointerUp = useCallback( (event: React.PointerEvent) => { if (activePointerIdRef.current !== event.pointerId) return; finishDrag(event.clientX); if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture?.(event.pointerId); } }, [finishDrag], ); const handleProgressKeyDown = useCallback( (event: React.KeyboardEvent) => { const video = videoRef.current; if (!video) return; let nextTime = video.currentTime; switch (event.key) { case "ArrowLeft": case "ArrowDown": nextTime -= 5; break; case "ArrowRight": case "ArrowUp": nextTime += 5; break; case "Home": nextTime = 0; break; case "End": nextTime = durationRef.current; break; default: return; } event.preventDefault(); event.stopPropagation(); seekTo(nextTime); resetIdleTimer(); }, [resetIdleTimer, seekTo], ); const skipTime = useCallback( (seconds: number) => { const video = videoRef.current; if (!video) return; seekTo(video.currentTime + seconds); resetIdleTimer(); }, [resetIdleTimer, seekTo], ); useEffect(() => { const video = videoRef.current; if (!video) return; const handlePlay = () => { isPlayingRef.current = true; setIsPlaying(true); setShowCenterButton(false); resetIdleTimer(); callbacksRef.current.onPlay?.(); }; const handlePause = () => { isPlayingRef.current = false; setIsPlaying(false); setShowCenterButton(true); showPlayerControls(); clearIdleTimer(); syncProgress(video.currentTime, true); callbacksRef.current.onPause?.(); }; const handleEnded = () => { isPlayingRef.current = false; setIsPlaying(false); setShowCenterButton(true); showPlayerControls(); clearIdleTimer(); syncProgress(video.currentTime, true); callbacksRef.current.onEnded?.(); }; const handleTimeUpdate = () => { if (!dragActiveRef.current) syncProgress(video.currentTime); callbacksRef.current.onTimeUpdate?.(video.currentTime); }; const handleLoadedMetadata = () => { const nextDuration = Number.isFinite(video.duration) ? video.duration : 0; durationRef.current = nextDuration; setDuration(nextDuration); setVolumeState(video.volume); setIsMuted(video.muted); syncProgress(video.currentTime, true); }; const handleProgress = () => { if (video.buffered.length > 0 && durationRef.current > 0) { const end = video.buffered.end(video.buffered.length - 1); applyBufferedProgress(end / durationRef.current); } else { applyBufferedProgress(0); } callbacksRef.current.onProgress?.(video.buffered); }; const handleVolume = () => { setVolumeState(video.volume); setIsMuted(video.muted); callbacksRef.current.onVolumeChange?.(video.volume, video.muted); }; const handleWaiting = () => setIsLoading(true); const handleCanPlay = () => { setIsLoading(false); setHasPlaybackError(false); }; const handleError = () => { setIsLoading(false); setHasPlaybackError(true); setShowCenterButton(false); showPlayerControls(); }; const handleSeeked = () => { if (!dragActiveRef.current) syncProgress(video.currentTime, true); }; video.addEventListener("play", handlePlay); video.addEventListener("pause", handlePause); video.addEventListener("ended", handleEnded); video.addEventListener("timeupdate", handleTimeUpdate); video.addEventListener("loadedmetadata", handleLoadedMetadata); video.addEventListener("progress", handleProgress); video.addEventListener("volumechange", handleVolume); video.addEventListener("waiting", handleWaiting); video.addEventListener("canplay", handleCanPlay); video.addEventListener("error", handleError); video.addEventListener("seeked", handleSeeked); return () => { video.removeEventListener("play", handlePlay); video.removeEventListener("pause", handlePause); video.removeEventListener("ended", handleEnded); video.removeEventListener("timeupdate", handleTimeUpdate); video.removeEventListener("loadedmetadata", handleLoadedMetadata); video.removeEventListener("progress", handleProgress); video.removeEventListener("volumechange", handleVolume); video.removeEventListener("waiting", handleWaiting); video.removeEventListener("canplay", handleCanPlay); video.removeEventListener("error", handleError); video.removeEventListener("seeked", handleSeeked); }; }, [ applyBufferedProgress, clearIdleTimer, resetIdleTimer, showPlayerControls, syncProgress, ]); useEffect(() => { const video = videoRef.current as VideoWithFrameCallbacks | null; if (!video || !isPlaying || isDragging) return; let active = true; let frameId: number | null = null; let frameKind: "video" | "animation" | null = null; const cancelFrame = () => { if (frameId === null) return; if (frameKind === "video" && video.cancelVideoFrameCallback) { video.cancelVideoFrameCallback(frameId); } else if (frameKind === "animation") { cancelAnimationFrame(frameId); } frameId = null; frameKind = null; }; const scheduleFrame = () => { if ( !active || document.hidden || video.paused || video.ended || frameId !== null ) { return; } if (video.requestVideoFrameCallback) { frameKind = "video"; frameId = video.requestVideoFrameCallback((_now, metadata) => { frameId = null; frameKind = null; const mediaTime = Number.isFinite(metadata.mediaTime) ? metadata.mediaTime : video.currentTime; syncProgress(mediaTime); scheduleFrame(); }); } else { frameKind = "animation"; frameId = requestAnimationFrame(() => { frameId = null; frameKind = null; syncProgress(video.currentTime); scheduleFrame(); }); } }; const handleVisibilityChange = () => { cancelFrame(); if (!document.hidden) scheduleFrame(); }; scheduleFrame(); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { active = false; cancelFrame(); document.removeEventListener( "visibilitychange", handleVisibilityChange, ); }; }, [isDragging, isPlaying, syncProgress]); useEffect(() => { const progress = progressRef.current; if (!progress) return; const updateWidth = () => { progressWidthRef.current = progress.clientWidth; applyVisualProgress(currentTimeRef.current); }; updateWidth(); if (typeof ResizeObserver !== "undefined") { const observer = new ResizeObserver(updateWidth); observer.observe(progress); return () => observer.disconnect(); } window.addEventListener("resize", updateWidth); return () => window.removeEventListener("resize", updateWidth); }, [applyVisualProgress]); useEffect(() => { if (supportsPointerEvents) return; const handleMouseMove = (event: MouseEvent) => { if (dragActiveRef.current) queueDragPreview(event.clientX); }; const handleMouseUp = (event: MouseEvent) => { if (dragActiveRef.current) finishDrag(event.clientX); }; const handleTouchMove = (event: TouchEvent) => { if (!dragActiveRef.current) return; event.preventDefault(); const touch = event.touches[0]; if (touch) queueDragPreview(touch.clientX); }; const handleTouchEnd = (event: TouchEvent) => { if (!dragActiveRef.current) return; const touch = event.changedTouches[0]; finishDrag(touch?.clientX); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); document.addEventListener("touchmove", handleTouchMove, { passive: false, }); document.addEventListener("touchend", handleTouchEnd); document.addEventListener("touchcancel", cancelDrag); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("touchmove", handleTouchMove); document.removeEventListener("touchend", handleTouchEnd); document.removeEventListener("touchcancel", cancelDrag); }; }, [cancelDrag, finishDrag, queueDragPreview, supportsPointerEvents]); useEffect(() => { return () => { clearIdleTimer(); if (dragFrameRef.current !== null) { cancelAnimationFrame(dragFrameRef.current); } }; }, [clearIdleTimer]); const handlePlayerKeyDown = useCallback( (event: React.KeyboardEvent) => { const video = videoRef.current; if (!video) return; switch (event.key) { case " ": event.preventDefault(); togglePlay(); break; case "ArrowLeft": event.preventDefault(); skipTime(-5); break; case "ArrowRight": event.preventDefault(); skipTime(5); break; case "ArrowUp": event.preventDefault(); video.volume = Math.min(1, video.volume + 0.1); break; case "ArrowDown": event.preventDefault(); video.volume = Math.max(0, video.volume - 0.1); break; case "f": if (allowFullscreenToggle) { event.preventDefault(); toggleFullscreen(); } break; case "m": event.preventDefault(); toggleMute(); break; } }, [ allowFullscreenToggle, skipTime, toggleFullscreen, toggleMute, togglePlay, ], ); return (
{ if ( event.target === videoRef.current || event.target === containerRef.current ) { togglePlay(); } }} >