'use client'; import { useRef, useState, useEffect } from 'react'; import { Icons } from '@/components/ui/Icon'; interface DesktopVideoPlayerProps { src: string; poster?: string; onError?: (error: string) => void; onTimeUpdate?: (currentTime: number, duration: number) => void; initialTime?: number; } export function DesktopVideoPlayer({ src, poster, onError, onTimeUpdate, initialTime = 0 }: DesktopVideoPlayerProps) { 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 [isPiPSupported, setIsPiPSupported] = useState(false); const [isAirPlaySupported, setIsAirPlaySupported] = useState(false); const [skipForwardAmount, setSkipForwardAmount] = useState(0); const [skipBackwardAmount, setSkipBackwardAmount] = useState(0); const [showSkipForwardIndicator, setShowSkipForwardIndicator] = useState(false); const [showSkipBackwardIndicator, setShowSkipBackwardIndicator] = useState(false); const [isSkipForwardAnimatingOut, setIsSkipForwardAnimatingOut] = useState(false); const [isSkipBackwardAnimatingOut, setIsSkipBackwardAnimatingOut] = useState(false); const [showVolumeBar, setShowVolumeBar] = useState(false); const [toastMessage, setToastMessage] = useState(null); const [showToast, setShowToast] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false); const controlsTimeoutRef = useRef(null); const speedMenuTimeoutRef = useRef(null); const skipForwardTimeoutRef = useRef(null); const skipBackwardTimeoutRef = useRef(null); const volumeBarTimeoutRef = useRef(null); const isDraggingProgressRef = useRef(false); const isDraggingVolumeRef = useRef(false); const mouseMoveThrottleRef = useRef(null); const toastTimeoutRef = useRef(null); const moreMenuTimeoutRef = useRef(null); // Check for PiP and AirPlay support useEffect(() => { if (typeof document !== 'undefined') { setIsPiPSupported('pictureInPictureEnabled' in document); } if (typeof window !== 'undefined') { // Check for AirPlay support (Safari/WebKit) setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window); } }, []); // 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 (throttled for performance) const handleMouseMove = () => { // Throttle mouse move events to improve performance if (mouseMoveThrottleRef.current) return; mouseMoveThrottleRef.current = setTimeout(() => { mouseMoveThrottleRef.current = null; }, 100); // Throttle to max 10 times per second 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); } }; // Show volume bar temporarily const showVolumeBarTemporarily = () => { setShowVolumeBar(true); // Clear existing timeout if (volumeBarTimeoutRef.current) { clearTimeout(volumeBarTimeoutRef.current); } // Hide after 1 second volumeBarTimeoutRef.current = setTimeout(() => { setShowVolumeBar(false); }, 1000); }; 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); }, []); // Picture-in-Picture const togglePictureInPicture = async () => { if (!videoRef.current || !isPiPSupported) return; try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); } else { await videoRef.current.requestPictureInPicture(); } } catch (error) { console.error('Failed to toggle Picture-in-Picture:', error); } }; // AirPlay const showAirPlayMenu = () => { if (!videoRef.current || !isAirPlaySupported) return; const video = videoRef.current as any; if (video.webkitShowPlaybackTargetPicker) { video.webkitShowPlaybackTargetPicker(); } }; // Skip forward/backward with visual feedback const skipForward = () => { if (!videoRef.current) return; // Clear backward indicator immediately setShowSkipBackwardIndicator(false); setSkipBackwardAmount(0); setIsSkipBackwardAnimatingOut(false); if (skipBackwardTimeoutRef.current) { clearTimeout(skipBackwardTimeoutRef.current); } // Clear existing timeout to reset the fade out timer if (skipForwardTimeoutRef.current) { clearTimeout(skipForwardTimeoutRef.current); } // Calculate new skip amount (accumulate if already showing) const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + 10 : 10; setSkipForwardAmount(newSkipAmount); setShowSkipForwardIndicator(true); setIsSkipForwardAnimatingOut(false); // Apply the skip to video const targetTime = Math.min(videoRef.current.currentTime + 10, duration); videoRef.current.currentTime = targetTime; setCurrentTime(targetTime); // Start fade out animation after 800ms of inactivity skipForwardTimeoutRef.current = setTimeout(() => { setIsSkipForwardAnimatingOut(true); // Hide indicator after animation completes (200ms) setTimeout(() => { setShowSkipForwardIndicator(false); setSkipForwardAmount(0); setIsSkipForwardAnimatingOut(false); }, 200); }, 800); }; const skipBackward = () => { if (!videoRef.current) return; // Clear forward indicator immediately setShowSkipForwardIndicator(false); setSkipForwardAmount(0); setIsSkipForwardAnimatingOut(false); if (skipForwardTimeoutRef.current) { clearTimeout(skipForwardTimeoutRef.current); } // Clear existing timeout to reset the fade out timer if (skipBackwardTimeoutRef.current) { clearTimeout(skipBackwardTimeoutRef.current); } // Calculate new skip amount (accumulate if already showing) const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + 10 : 10; setSkipBackwardAmount(newSkipAmount); setShowSkipBackwardIndicator(true); setIsSkipBackwardAnimatingOut(false); // Apply the skip to video const targetTime = Math.max(videoRef.current.currentTime - 10, 0); videoRef.current.currentTime = targetTime; setCurrentTime(targetTime); // Start fade out animation after 800ms of inactivity skipBackwardTimeoutRef.current = setTimeout(() => { setIsSkipBackwardAnimatingOut(true); // Hide indicator after animation completes (200ms) setTimeout(() => { setShowSkipBackwardIndicator(false); setSkipBackwardAmount(0); setIsSkipBackwardAnimatingOut(false); }, 200); }, 800); }; // Cleanup timeout on unmount useEffect(() => { return () => { if (skipForwardTimeoutRef.current) { clearTimeout(skipForwardTimeoutRef.current); } if (skipBackwardTimeoutRef.current) { clearTimeout(skipBackwardTimeoutRef.current); } if (volumeBarTimeoutRef.current) { clearTimeout(volumeBarTimeoutRef.current); } }; }, []); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ignore if user is typing in an input field const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } // Prevent default for our shortcuts const shortcuts = [' ', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'f', 'F', 'm', 'M', 'i', 'I', '<', '>', ',', '.']; if (shortcuts.includes(e.key)) { e.preventDefault(); // Show controls when any shortcut key is pressed setShowControls(true); if (controlsTimeoutRef.current) { clearTimeout(controlsTimeoutRef.current); } // Hide controls after 3 seconds if video is playing if (isPlaying) { controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000); } } switch (e.key) { // Playback control case ' ': // Spacebar: Play/pause togglePlay(); break; case 'ArrowLeft': // Left arrow: Rewind 10 seconds case '<': // <: Rewind 10 seconds case ',': // Comma key skipBackward(); break; case 'ArrowRight': // Right arrow: Fast-forward 10 seconds case '>': // >: Fast-forward 10 seconds case '.': // Period key skipForward(); break; // Volume case 'm': // M: Mute/unmute case 'M': toggleMute(); showVolumeBarTemporarily(); break; case 'ArrowUp': // Up arrow: Increase volume by 5% if (videoRef.current) { const newVolume = Math.min(1, volume + 0.05); setVolume(newVolume); videoRef.current.volume = newVolume; setIsMuted(newVolume === 0); showVolumeBarTemporarily(); } break; case 'ArrowDown': // Down arrow: Decrease volume by 5% if (videoRef.current) { const newVolume = Math.max(0, volume - 0.05); setVolume(newVolume); videoRef.current.volume = newVolume; setIsMuted(newVolume === 0); showVolumeBarTemporarily(); } break; // Display case 'f': // F: Toggle full-screen mode case 'F': toggleFullscreen(); break; case 'i': // I: Toggle miniplayer (Picture-in-Picture) case 'I': if (isPiPSupported) { togglePictureInPicture(); } break; } }; // Add event listener window.addEventListener('keydown', handleKeyDown); // Cleanup return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [isPlaying, volume, isMuted, isPiPSupported, togglePlay, toggleMute, toggleFullscreen, togglePictureInPicture, skipForward, skipBackward, showVolumeBarTemporarily]); // Include dependencies // 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); // Clear timeout when manually closing if (speedMenuTimeoutRef.current) { clearTimeout(speedMenuTimeoutRef.current); } }; // Show toast notification const showToastNotification = (message: string) => { setToastMessage(message); setShowToast(true); if (toastTimeoutRef.current) { clearTimeout(toastTimeoutRef.current); } toastTimeoutRef.current = setTimeout(() => { setShowToast(false); setTimeout(() => setToastMessage(null), 300); }, 3000); }; // Copy video link const handleCopyLink = async () => { try { await navigator.clipboard.writeText(src); showToastNotification('链接已复制到剪贴板'); } catch (error) { console.error('Copy failed:', error); showToastNotification('复制失败,请重试'); } }; // Auto-hide speed menu after 1.5s of inactivity const startSpeedMenuTimeout = () => { if (speedMenuTimeoutRef.current) { clearTimeout(speedMenuTimeoutRef.current); } speedMenuTimeoutRef.current = setTimeout(() => { setShowSpeedMenu(false); }, 1500); }; const clearSpeedMenuTimeout = () => { if (speedMenuTimeoutRef.current) { clearTimeout(speedMenuTimeoutRef.current); } }; // Start timeout when menu opens useEffect(() => { if (showSpeedMenu) { startSpeedMenuTimeout(); } else { clearSpeedMenuTimeout(); } return () => clearSpeedMenuTimeout(); }, [showSpeedMenu]); // Format time helper const formatTime = (seconds: number) => { if (isNaN(seconds)) return '0:00:00'; const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; return (
isPlaying && setShowControls(false)} > {/* Video Element */}