diff --git a/app/globals.css b/app/globals.css index 6217178..6929bc9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -344,3 +344,92 @@ body.dark, } } +/* Custom Video Player Styles */ +.spinner { + width: 48px; + height: 48px; + border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent); + border-top-color: var(--accent-color); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; +} + +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-2xl); + color: white; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-icon:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} + +.btn-icon:active { + transform: scale(0.95); +} + +.slider-track { + position: relative; + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.3); + border-radius: var(--radius-full); + cursor: pointer; + overflow: visible; + user-select: none; + -webkit-user-select: none; +} + +.slider-track.h-1 { + height: 4px; +} + +.slider-range { + position: absolute; + left: 0; + top: 0; + height: 100%; + background-color: var(--accent-color); + border-radius: var(--radius-full); + pointer-events: none; +} + +.slider-thumb { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + background-color: white; + border-radius: var(--radius-full); + transform: translate(-50%, -50%); + cursor: grab; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.slider-track.h-1 .slider-thumb { + width: 12px; + height: 12px; +} + +.slider-track:hover .slider-thumb { + transform: translate(-50%, -50%) scale(1.2); +} + +.slider-thumb:active, +.slider-track:active .slider-thumb { + transform: translate(-50%, -50%) scale(1.3); + cursor: grabbing; +} + + diff --git a/components/player/CustomVideoPlayer.tsx b/components/player/CustomVideoPlayer.tsx new file mode 100644 index 0000000..1186133 --- /dev/null +++ b/components/player/CustomVideoPlayer.tsx @@ -0,0 +1,443 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; +import { Icons } from '@/components/ui/Icon'; + +interface CustomVideoPlayerProps { + src: string; + poster?: string; + onError?: (error: string) => void; + onTimeUpdate?: (currentTime: number, duration: number) => void; + initialTime?: number; +} + +export function CustomVideoPlayer({ + src, + poster, + onError, + onTimeUpdate, + initialTime = 0 +}: CustomVideoPlayerProps) { + const videoRef = useRef(null); + const containerRef = useRef(null); + const progressBarRef = useRef(null); + const volumeBarRef = useRef(null); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showControls, setShowControls] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [playbackRate, setPlaybackRate] = useState(1); + const [showSpeedMenu, setShowSpeedMenu] = useState(false); + + const controlsTimeoutRef = useRef(null); + const isDraggingProgressRef = useRef(false); + const isDraggingVolumeRef = useRef(false); + + // Auto-hide controls + useEffect(() => { + if (!isPlaying) return; + + const hideControls = () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + controlsTimeoutRef.current = setTimeout(() => { + if (isPlaying && !showSpeedMenu) { + setShowControls(false); + } + }, 3000); + }; + + hideControls(); + + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; + }, [isPlaying, showSpeedMenu]); + + // Handle mouse movement to show controls + const handleMouseMove = () => { + setShowControls(true); + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + if (isPlaying && !showSpeedMenu) { + controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000); + } + }; + + // Play/Pause toggle + const togglePlay = () => { + if (!videoRef.current) return; + + if (isPlaying) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + }; + + // Handle video events + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + const handleTimeUpdateEvent = () => { + if (!videoRef.current || isDraggingProgressRef.current) return; + + const current = videoRef.current.currentTime; + const total = videoRef.current.duration; + + setCurrentTime(current); + setDuration(total); + + if (onTimeUpdate) { + onTimeUpdate(current, total); + } + }; + + const handleLoadedMetadata = () => { + if (!videoRef.current) return; + + setDuration(videoRef.current.duration); + setIsLoading(false); + + // Set initial time if provided + if (initialTime > 0) { + videoRef.current.currentTime = initialTime; + } + }; + + const handleVideoError = () => { + setIsLoading(false); + if (onError) { + onError('Video failed to load'); + } + }; + + // Progress bar seeking + const handleProgressClick = (e: React.MouseEvent) => { + if (!videoRef.current || !progressBarRef.current) return; + + const rect = progressBarRef.current.getBoundingClientRect(); + const pos = (e.clientX - rect.left) / rect.width; + const newTime = pos * duration; + videoRef.current.currentTime = newTime; + setCurrentTime(newTime); + }; + + const handleProgressMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + isDraggingProgressRef.current = true; + handleProgressClick(e); + }; + + useEffect(() => { + const handleProgressMouseMove = (e: MouseEvent) => { + if (!isDraggingProgressRef.current || !progressBarRef.current || !videoRef.current) return; + + e.preventDefault(); + const rect = progressBarRef.current.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newTime = pos * duration; + videoRef.current.currentTime = newTime; + setCurrentTime(newTime); + }; + + const handleMouseUp = () => { + if (isDraggingProgressRef.current) { + isDraggingProgressRef.current = false; + } + }; + + document.addEventListener('mousemove', handleProgressMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleProgressMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [duration]); + + // Volume control + const toggleMute = () => { + if (!videoRef.current) return; + + if (isMuted) { + videoRef.current.volume = volume; + setIsMuted(false); + } else { + videoRef.current.volume = 0; + setIsMuted(true); + } + }; + + const handleVolumeChange = (e: React.MouseEvent) => { + if (!videoRef.current || !volumeBarRef.current) return; + + const rect = volumeBarRef.current.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + setVolume(pos); + videoRef.current.volume = pos; + setIsMuted(pos === 0); + }; + + const handleVolumeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + isDraggingVolumeRef.current = true; + handleVolumeChange(e); + }; + + useEffect(() => { + const handleVolumeMouseMove = (e: MouseEvent) => { + if (!isDraggingVolumeRef.current || !volumeBarRef.current || !videoRef.current) return; + + e.preventDefault(); + const rect = volumeBarRef.current.getBoundingClientRect(); + const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + setVolume(pos); + videoRef.current.volume = pos; + setIsMuted(pos === 0); + }; + + const handleMouseUp = () => { + if (isDraggingVolumeRef.current) { + isDraggingVolumeRef.current = false; + } + }; + + document.addEventListener('mousemove', handleVolumeMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleVolumeMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + // Fullscreen + const toggleFullscreen = () => { + if (!containerRef.current) return; + + if (!isFullscreen) { + if (containerRef.current.requestFullscreen) { + containerRef.current.requestFullscreen(); + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + }; + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + // Playback speed + const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; + + const changePlaybackSpeed = (speed: number) => { + if (!videoRef.current) return; + videoRef.current.playbackRate = speed; + setPlaybackRate(speed); + setShowSpeedMenu(false); + }; + + // Format time helper + const formatTime = (seconds: number) => { + if (isNaN(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + return ( +
isPlaying && setShowControls(false)} + > + {/* Video Element */} +