mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 01:03:42 +08:00
904 lines
30 KiB
TypeScript
904 lines
30 KiB
TypeScript
'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<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
const volumeBarRef = useRef<HTMLDivElement>(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<string | null>(null);
|
|
const [showToast, setShowToast] = useState(false);
|
|
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
|
|
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const speedMenuTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const skipForwardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const skipBackwardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const volumeBarTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const isDraggingProgressRef = useRef(false);
|
|
const isDraggingVolumeRef = useRef(false);
|
|
const mouseMoveThrottleRef = useRef<NodeJS.Timeout | null>(null);
|
|
const toastTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const moreMenuTimeoutRef = useRef<NodeJS.Timeout | null>(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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden group"
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={() => isPlaying && setShowControls(false)}
|
|
>
|
|
{/* Video Element */}
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-contain"
|
|
src={src}
|
|
poster={poster}
|
|
onPlay={handlePlay}
|
|
onPause={handlePause}
|
|
onTimeUpdate={handleTimeUpdateEvent}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
onError={handleVideoError}
|
|
onWaiting={() => setIsLoading(true)}
|
|
onCanPlay={() => setIsLoading(false)}
|
|
onClick={togglePlay}
|
|
/>
|
|
|
|
{/* Loading Spinner */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="spinner"></div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Skip Forward Indicator */}
|
|
{showSkipForwardIndicator && (
|
|
<div className="absolute top-1/2 right-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
|
|
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${
|
|
isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
|
}`}>
|
|
+{skipForwardAmount}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Skip Backward Indicator */}
|
|
{showSkipBackwardIndicator && (
|
|
<div className="absolute top-1/2 left-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
|
|
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${
|
|
isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
|
|
}`}>
|
|
-{skipBackwardAmount}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Center Play Button (when paused) */}
|
|
{!isPlaying && !isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<button
|
|
onClick={togglePlay}
|
|
className="pointer-events-auto w-20 h-20 rounded-full bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] flex items-center justify-center transition-all duration-300 hover:scale-110 hover:bg-[var(--accent-color)] shadow-[var(--shadow-md)] will-change-transform"
|
|
aria-label="Play"
|
|
>
|
|
<Icons.Play size={32} className="text-white ml-1" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom Controls */}
|
|
<div
|
|
className={`absolute bottom-0 left-0 right-0 transition-all duration-300 ${
|
|
showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
|
}`}
|
|
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
|
|
>
|
|
{/* Progress Bar */}
|
|
<div className="px-4 pb-1">
|
|
<div
|
|
ref={progressBarRef}
|
|
className="slider-track cursor-pointer"
|
|
onClick={handleProgressClick}
|
|
onMouseDown={handleProgressMouseDown}
|
|
style={{ pointerEvents: 'auto' }}
|
|
>
|
|
<div
|
|
className="slider-range"
|
|
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
|
/>
|
|
<div
|
|
className="slider-thumb"
|
|
style={{ left: `${(currentTime / duration) * 100 || 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls Bar */}
|
|
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
|
|
<div className="flex items-center justify-between gap-4">
|
|
{/* Left Controls */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Play/Pause */}
|
|
<button
|
|
onClick={togglePlay}
|
|
className="btn-icon"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
|
</button>
|
|
|
|
{/* Skip Backward 10s */}
|
|
<button
|
|
onClick={skipBackward}
|
|
className="btn-icon"
|
|
aria-label="Skip backward 10 seconds"
|
|
title="后退 10 秒"
|
|
>
|
|
<Icons.SkipBack size={20} />
|
|
</button>
|
|
|
|
{/* Skip Forward 10s */}
|
|
<button
|
|
onClick={skipForward}
|
|
className="btn-icon"
|
|
aria-label="Skip forward 10 seconds"
|
|
title="快进 10 秒"
|
|
>
|
|
<Icons.SkipForward size={20} />
|
|
</button>
|
|
|
|
{/* Volume */}
|
|
<div className="flex items-center gap-2 group/volume">
|
|
<button
|
|
onClick={toggleMute}
|
|
className="btn-icon"
|
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
|
>
|
|
{isMuted || volume === 0 ? (
|
|
<Icons.VolumeX size={20} />
|
|
) : volume < 0.5 ? (
|
|
<Icons.Volume1 size={20} />
|
|
) : (
|
|
<Icons.Volume2 size={20} />
|
|
)}
|
|
</button>
|
|
|
|
{/* Volume Bar */}
|
|
<div className={`flex items-center gap-2 overflow-hidden transition-all duration-300 ${
|
|
showVolumeBar
|
|
? 'opacity-100 w-32'
|
|
: 'opacity-0 w-0 group-hover/volume:opacity-100 group-hover/volume:w-32'
|
|
}`}>
|
|
<div
|
|
ref={volumeBarRef}
|
|
className="slider-track h-1 cursor-pointer flex-1"
|
|
onClick={handleVolumeChange}
|
|
onMouseDown={handleVolumeMouseDown}
|
|
>
|
|
<div
|
|
className="slider-range h-full"
|
|
style={{ width: `${isMuted ? 0 : volume * 100}%` }}
|
|
/>
|
|
<div
|
|
className="slider-thumb"
|
|
style={{ left: `${isMuted ? 0 : volume * 100}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
|
|
{Math.round((isMuted ? 0 : volume) * 100)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Time */}
|
|
<span className="text-white text-sm font-medium tabular-nums">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Right Controls */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Playback Speed */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
|
onMouseEnter={clearSpeedMenuTimeout}
|
|
onMouseLeave={startSpeedMenuTimeout}
|
|
className="btn-icon text-xs font-semibold min-w-[2.5rem]"
|
|
aria-label="Playback speed"
|
|
>
|
|
{playbackRate}x
|
|
</button>
|
|
|
|
{/* Speed Menu */}
|
|
{showSpeedMenu && (
|
|
<div
|
|
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]"
|
|
onMouseEnter={clearSpeedMenuTimeout}
|
|
onMouseLeave={() => setShowSpeedMenu(false)}
|
|
>
|
|
{speeds.map((speed) => (
|
|
<button
|
|
key={speed}
|
|
onClick={() => changePlaybackSpeed(speed)}
|
|
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${
|
|
playbackRate === speed
|
|
? 'bg-[var(--accent-color)] text-white'
|
|
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
|
|
}`}
|
|
>
|
|
{speed}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Picture-in-Picture */}
|
|
{isPiPSupported && (
|
|
<button
|
|
onClick={togglePictureInPicture}
|
|
className="btn-icon"
|
|
aria-label="Picture-in-Picture"
|
|
title="画中画"
|
|
>
|
|
<Icons.PictureInPicture size={20} />
|
|
</button>
|
|
)}
|
|
|
|
{/* AirPlay */}
|
|
{isAirPlaySupported && (
|
|
<button
|
|
onClick={showAirPlayMenu}
|
|
className="btn-icon"
|
|
aria-label="AirPlay"
|
|
title="AirPlay"
|
|
>
|
|
<Icons.Airplay size={20} />
|
|
</button>
|
|
)}
|
|
|
|
{/* More Menu */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowMoreMenu(!showMoreMenu)}
|
|
onMouseEnter={() => {
|
|
if (moreMenuTimeoutRef.current) {
|
|
clearTimeout(moreMenuTimeoutRef.current);
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
moreMenuTimeoutRef.current = setTimeout(() => {
|
|
setShowMoreMenu(false);
|
|
}, 300);
|
|
}}
|
|
className="btn-icon"
|
|
aria-label="More options"
|
|
title="更多选项"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="1"/>
|
|
<circle cx="12" cy="5" r="1"/>
|
|
<circle cx="12" cy="19" r="1"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* More Menu Dropdown */}
|
|
{showMoreMenu && (
|
|
<div
|
|
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[180px]"
|
|
onMouseEnter={() => {
|
|
if (moreMenuTimeoutRef.current) {
|
|
clearTimeout(moreMenuTimeoutRef.current);
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
setShowMoreMenu(false);
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
setShowMoreMenu(false);
|
|
handleCopyLink();
|
|
}}
|
|
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3"
|
|
>
|
|
<Icons.Link size={18} />
|
|
<span>复制链接</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Fullscreen */}
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="btn-icon"
|
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
>
|
|
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toast Notification */}
|
|
{showToast && toastMessage && (
|
|
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-[200] animate-slide-up">
|
|
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px]">
|
|
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
|
|
<span className="text-white text-sm font-medium">{toastMessage}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|