mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: add MobileVideoPlayer component with advanced controls and hooks
- Implemented MobileVideoPlayer component for mobile video playback. - Added features such as play/pause, skip forward/backward, volume control, and fullscreen support. - Integrated double-tap gesture handling for skipping video. - Included hooks for screen orientation management and mobile detection. - Enhanced user experience with loading indicators and playback speed options.
This commit is contained in:
@@ -447,4 +447,16 @@ body.dark,
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+9
-7
@@ -80,13 +80,13 @@ function PlayerContent() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)]">
|
||||
{/* Glass Navbar */}
|
||||
<nav className="sticky top-0 z-50 pt-4 pb-2">
|
||||
<div className="max-w-7xl mx-auto mx-4 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_40%,transparent)] px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4">
|
||||
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_40%,transparent)] px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="flex items-center justify-center hover:opacity-80 transition-opacity"
|
||||
className="flex items-center justify-center hover:opacity-80 transition-opacity flex-shrink-0"
|
||||
title="返回首页"
|
||||
>
|
||||
<Image
|
||||
@@ -103,10 +103,12 @@ function PlayerContent() {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={20} />
|
||||
<span>返回</span>
|
||||
<span className="hidden sm:inline">返回</span>
|
||||
</Button>
|
||||
</div>
|
||||
<ThemeSwitcher />
|
||||
<div className="flex-shrink-0">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useIsMobile } from '@/lib/hooks/useMobilePlayer';
|
||||
import { DesktopVideoPlayer } from './DesktopVideoPlayer';
|
||||
import { MobileVideoPlayer } from './MobileVideoPlayer';
|
||||
|
||||
interface CustomVideoPlayerProps {
|
||||
src: string;
|
||||
@@ -11,791 +12,15 @@ interface CustomVideoPlayerProps {
|
||||
initialTime?: number;
|
||||
}
|
||||
|
||||
export function CustomVideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
initialTime = 0
|
||||
}: CustomVideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const volumeBarRef = useRef<HTMLDivElement>(null);
|
||||
/**
|
||||
* Smart Video Player that renders different versions based on device
|
||||
* - Mobile/Tablet: Optimized touch controls, double-tap gestures, orientation lock
|
||||
* - Desktop: Full-featured player with hover interactions
|
||||
*/
|
||||
export function CustomVideoPlayer(props: CustomVideoPlayerProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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 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);
|
||||
|
||||
// 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
|
||||
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<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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${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)]"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
return isMobile
|
||||
? <MobileVideoPlayer {...props} />
|
||||
: <DesktopVideoPlayer {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
'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 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);
|
||||
|
||||
// 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
|
||||
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<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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${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)]"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,23 +17,23 @@ interface EpisodeListProps {
|
||||
|
||||
export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: EpisodeListProps) {
|
||||
return (
|
||||
<Card hover={false} className="sticky top-32">
|
||||
<h3 className="text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<Icons.List size={24} />
|
||||
<Card hover={false} className="lg:sticky lg:top-32">
|
||||
<h3 className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<Icons.List size={20} className="sm:w-6 sm:h-6" />
|
||||
<span>选集</span>
|
||||
{episodes && (
|
||||
<Badge variant="primary">{episodes.length}</Badge>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto space-y-2 pr-2">
|
||||
<div className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2">
|
||||
{episodes && episodes.length > 0 ? (
|
||||
episodes.map((episode, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onEpisodeClick(episode, index)}
|
||||
className={`
|
||||
w-full px-4 py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)]
|
||||
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)]
|
||||
${currentEpisode === index
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
@@ -41,7 +41,7 @@ export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: Episod
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-sm sm:text-base">
|
||||
{episode.name || `第 ${index + 1} 集`}
|
||||
</span>
|
||||
{currentEpisode === index && (
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useDoubleTap, useScreenOrientation } from '@/lib/hooks/useMobilePlayer';
|
||||
|
||||
interface MobileVideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
||||
initialTime?: number;
|
||||
}
|
||||
|
||||
export function MobileVideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
initialTime = 0
|
||||
}: MobileVideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressBarRef = 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 [showVolumeMenu, setShowVolumeMenu] = useState(false);
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
const [isPiPSupported, setIsPiPSupported] = useState(false);
|
||||
const [skipAmount, setSkipAmount] = useState(0);
|
||||
const [skipSide, setSkipSide] = useState<'left' | 'right' | null>(null);
|
||||
const [showSkipIndicator, setShowSkipIndicator] = useState(false);
|
||||
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
|
||||
// Screen orientation management
|
||||
useScreenOrientation(isFullscreen);
|
||||
|
||||
// Check for PiP support
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
setIsPiPSupported('pictureInPictureEnabled' in document);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Skip forward/backward with indicator
|
||||
const skipVideo = (seconds: number, side: 'left' | 'right') => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
|
||||
// If same side, accumulate
|
||||
const newSkipAmount = skipSide === side ? skipAmount + Math.abs(seconds) : Math.abs(seconds);
|
||||
setSkipAmount(newSkipAmount);
|
||||
setSkipSide(side);
|
||||
setShowSkipIndicator(true);
|
||||
|
||||
// Apply skip
|
||||
const targetTime = side === 'left'
|
||||
? Math.max(videoRef.current.currentTime - Math.abs(seconds), 0)
|
||||
: Math.min(videoRef.current.currentTime + Math.abs(seconds), duration);
|
||||
|
||||
videoRef.current.currentTime = targetTime;
|
||||
setCurrentTime(targetTime);
|
||||
|
||||
// Hide indicator after 800ms
|
||||
skipTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipIndicator(false);
|
||||
setSkipAmount(0);
|
||||
setSkipSide(null);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
// Double tap handler
|
||||
const { handleTap } = useDoubleTap({
|
||||
onDoubleTapLeft: () => skipVideo(10, 'left'),
|
||||
onDoubleTapRight: () => skipVideo(10, 'right'),
|
||||
onSingleTap: () => {
|
||||
// Single tap in center: toggle play/pause
|
||||
togglePlay();
|
||||
// Also show controls briefly
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-hide controls
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const hideControls = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
setShowSpeedMenu(false);
|
||||
setShowVolumeMenu(false);
|
||||
setShowMoreMenu(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
hideControls();
|
||||
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
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> | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressBarRef.current) return;
|
||||
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const pos = (clientX - rect.left) / rect.width;
|
||||
const newTime = pos * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!videoRef.current) return;
|
||||
if (isMuted) {
|
||||
videoRef.current.volume = volume;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
videoRef.current.volume = 0;
|
||||
setIsMuted(true);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const changePlaybackSpeed = (speed: number) => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.playbackRate = speed;
|
||||
setPlaybackRate(speed);
|
||||
setShowSpeedMenu(false);
|
||||
};
|
||||
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden"
|
||||
>
|
||||
{/* 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)}
|
||||
onTouchEnd={handleTap}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Loading Spinner */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Indicators */}
|
||||
{showSkipIndicator && skipSide && (
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 pointer-events-none ${
|
||||
skipSide === 'left' ? 'left-8' : 'right-8'
|
||||
}`}>
|
||||
<div className="text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] animate-scale-in">
|
||||
{skipSide === 'left' ? '-' : '+'}{skipAmount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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-2">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="h-1 bg-white/30 rounded-full cursor-pointer"
|
||||
onClick={handleProgressClick}
|
||||
onTouchEnd={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)] rounded-full relative"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-2 sm:px-4 pb-3 sm:pb-4 pt-2">
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-2">
|
||||
{/* Left: Play + Skip + Time */}
|
||||
<div className="flex items-center gap-1 sm:gap-2 min-w-0">
|
||||
<button onClick={togglePlay} className="btn-icon p-2 sm:p-2.5 flex-shrink-0" aria-label={isPlaying ? 'Pause' : 'Play'}>
|
||||
{isPlaying ? <Icons.Pause size={20} className="sm:w-[22px] sm:h-[22px]" /> : <Icons.Play size={20} className="sm:w-[22px] sm:h-[22px]" />}
|
||||
</button>
|
||||
<button onClick={() => skipVideo(10, 'left')} className="btn-icon p-1.5 sm:p-2 flex-shrink-0" aria-label="后退10秒">
|
||||
<Icons.SkipBack size={16} className="sm:w-[18px] sm:h-[18px]" />
|
||||
</button>
|
||||
<button onClick={() => skipVideo(10, 'right')} className="btn-icon p-1.5 sm:p-2 flex-shrink-0" aria-label="快进10秒">
|
||||
<Icons.SkipForward size={16} className="sm:w-[18px] sm:h-[18px]" />
|
||||
</button>
|
||||
|
||||
{/* Time Display */}
|
||||
<span className="text-white text-[10px] sm:text-xs font-medium tabular-nums whitespace-nowrap">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: More + Fullscreen */}
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
{/* More Menu Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMoreMenu(!showMoreMenu)}
|
||||
className="btn-icon p-1.5 sm:p-2 flex-shrink-0"
|
||||
aria-label="更多"
|
||||
>
|
||||
<svg width="18" height="18" className="sm:w-5 sm:h-5" 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 min-w-[160px] z-[100]">
|
||||
<div className="bg-[rgba(255,255,255,0.1)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-[rgba(255,255,255,0.2)] shadow-[0_8px_32px_rgba(0,0,0,0.4)] overflow-hidden">
|
||||
{/* Volume Option */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMoreMenu(false);
|
||||
setShowVolumeMenu(true);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all"
|
||||
>
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
<span>音量</span>
|
||||
</button>
|
||||
|
||||
{/* Speed Option */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMoreMenu(false);
|
||||
setShowSpeedMenu(true);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>速度 {playbackRate}x</span>
|
||||
</button>
|
||||
|
||||
{/* PiP Option */}
|
||||
{isPiPSupported && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMoreMenu(false);
|
||||
togglePictureInPicture();
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/20 flex items-center gap-3 transition-all"
|
||||
>
|
||||
<Icons.PictureInPicture size={18} />
|
||||
<span>画中画</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button onClick={toggleFullscreen} className="btn-icon p-1.5 sm:p-2 flex-shrink-0" aria-label={isFullscreen ? '退出全屏' : '全屏'}>
|
||||
{isFullscreen ? <Icons.Minimize size={18} className="sm:w-5 sm:h-5" /> : <Icons.Maximize size={18} className="sm:w-5 sm:h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume Menu (shown after clicking from More menu) */}
|
||||
{showVolumeMenu && (
|
||||
<div className="mt-3 pt-3 border-t border-white/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={toggleMute} className="btn-icon p-2">
|
||||
{isMuted || volume === 0 ? <Icons.VolumeX size={18} /> : <Icons.Volume2 size={18} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={isMuted ? 0 : volume * 100}
|
||||
onChange={(e) => {
|
||||
const newVolume = parseFloat(e.target.value) / 100;
|
||||
setVolume(newVolume);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
}
|
||||
setIsMuted(newVolume === 0);
|
||||
}}
|
||||
className="w-full h-1 bg-white/30 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
|
||||
{Math.round((isMuted ? 0 : volume) * 100)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowVolumeMenu(false)}
|
||||
className="text-white/60 hover:text-white text-xs"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speed Menu (shown after clicking from More menu) */}
|
||||
{showSpeedMenu && (
|
||||
<div className="mt-3 pt-3 border-t border-white/20">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{speeds.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => {
|
||||
changePlaybackSpeed(speed);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-xs font-medium transition-colors ${
|
||||
playbackRate === speed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-white/20 text-white hover:bg-white/30'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowSpeedMenu(false)}
|
||||
className="ml-auto text-white/60 hover:text-white text-xs px-2"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,16 +14,16 @@ interface VideoMetadataProps {
|
||||
export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) {
|
||||
return (
|
||||
<Card hover={false}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||
{videoData?.vod_pic && (
|
||||
<img
|
||||
src={videoData.vod_pic}
|
||||
alt={videoData.vod_name}
|
||||
className="w-32 h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
|
||||
className="w-24 h-36 sm:w-32 sm:h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-[var(--text-color)] mb-3">
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-[var(--text-color)] mb-3">
|
||||
{videoData?.vod_name || title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
@@ -50,18 +50,18 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
||||
)}
|
||||
</div>
|
||||
{videoData?.vod_content && (
|
||||
<p className="text-[var(--text-secondary)] line-clamp-3">
|
||||
<p className="text-sm sm:text-base text-[var(--text-secondary)] line-clamp-3">
|
||||
{videoData.vod_content.replace(/<[^>]*>/g, '')}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_actor && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-2">
|
||||
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-2">
|
||||
<span className="font-semibold">主演:</span>
|
||||
{videoData.vod_actor}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_director && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-1">
|
||||
<span className="font-semibold">导演:</span>
|
||||
{videoData.vod_director}
|
||||
</p>
|
||||
|
||||
@@ -78,13 +78,14 @@ export function SearchForm({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-lg pr-32"
|
||||
className="text-lg pr-24 md:pr-32 truncate"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-32 top-1/2 -translate-y-1/2 p-2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors"
|
||||
className="absolute right-20 md:right-32 top-1/2 -translate-y-1/2 p-3 md:p-2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors touch-manipulation"
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<Icons.X size={20} />
|
||||
</button>
|
||||
@@ -93,11 +94,11 @@ export function SearchForm({
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 md:px-8"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
<span className="hidden sm:inline">搜索</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
@@ -20,6 +21,7 @@ interface TypeBadgesProps {
|
||||
* TypeBadges - Displays collected type badges from search results
|
||||
* Auto-collects unique type_name values and shows counts
|
||||
* Badges disappear when all videos of that type are removed
|
||||
* Responsive: Desktop shows expand/collapse, Mobile shows horizontal scroll
|
||||
*/
|
||||
export function TypeBadges({
|
||||
badges,
|
||||
@@ -27,6 +29,8 @@ export function TypeBadges({
|
||||
onToggleType,
|
||||
className = ''
|
||||
}: TypeBadgesProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (badges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -44,41 +48,99 @@ export function TypeBadges({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selectedTypes.has(badge.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.type}
|
||||
onClick={() => onToggleType(badge.type)}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
hover:scale-105 hover:shadow-[var(--shadow-sm)]
|
||||
active:scale-95
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{badge.type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{badge.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Desktop: Expandable Grid */}
|
||||
<div className="hidden md:flex md:flex-col md:flex-1">
|
||||
<div className={`flex items-center gap-2 flex-wrap transition-all duration-300 ${
|
||||
!isExpanded ? 'max-h-[2.5rem] overflow-hidden' : ''
|
||||
}`}>
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selectedTypes.has(badge.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.type}
|
||||
onClick={() => onToggleType(badge.type)}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
hover:scale-105 hover:shadow-[var(--shadow-sm)]
|
||||
active:scale-95
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{badge.type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{badge.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{badges.length > 5 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start"
|
||||
>
|
||||
<span>{isExpanded ? '收起' : '展开更多'}</span>
|
||||
<Icons.ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile & Tablet: Horizontal Scroll */}
|
||||
<div className="flex md:hidden flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selectedTypes.has(badge.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.type}
|
||||
onClick={() => onToggleType(badge.type)}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium whitespace-nowrap
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
active:scale-95 snap-start
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{badge.type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{badge.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -23,12 +24,32 @@ interface VideoGridProps {
|
||||
}
|
||||
|
||||
export function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent, videoId: string, videoUrl: string) => {
|
||||
// Check if it's a mobile device
|
||||
const isMobile = window.innerWidth < 1024; // lg breakpoint
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, first click shows details, second click navigates
|
||||
if (activeCardId === videoId) {
|
||||
// Already active, allow navigation
|
||||
window.location.href = videoUrl;
|
||||
} else {
|
||||
// First click, show details
|
||||
e.preventDefault();
|
||||
setActiveCardId(videoId);
|
||||
}
|
||||
}
|
||||
// On desktop, let the Link work normally
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 ${className}`}>
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 md:gap-4 lg:gap-6 ${className}`}>
|
||||
{videos.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
@@ -36,10 +57,14 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
const cardId = `${video.vod_id}-${index}`;
|
||||
const isActive = activeCardId === cardId;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${video.vod_id}-${index}`}
|
||||
key={cardId}
|
||||
href={videoUrl}
|
||||
onClick={(e) => handleCardClick(e, cardId, videoUrl)}
|
||||
>
|
||||
<Card
|
||||
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
|
||||
@@ -69,9 +94,17 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
|
||||
}`}>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{/* Mobile indicator when active */}
|
||||
{isActive && (
|
||||
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
|
||||
再次点击播放 →
|
||||
</div>
|
||||
)}
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
|
||||
@@ -16,9 +16,9 @@ export function Badge({ children, variant = 'primary', className = '' }: BadgePr
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
px-3 py-1
|
||||
px-2 py-0.5 md:px-3 md:py-1
|
||||
rounded-[var(--radius-full)]
|
||||
text-xs font-semibold
|
||||
text-[10px] md:text-xs font-semibold
|
||||
transition-all duration-200
|
||||
${variants[variant]}
|
||||
${className}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function Button({
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = "inline-flex items-center justify-center px-6 py-3 font-semibold text-base transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const baseStyles = "inline-flex items-center justify-center px-4 py-2.5 md:px-6 md:py-3 font-semibold text-sm md:text-base transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] touch-manipulation";
|
||||
|
||||
const variants = {
|
||||
primary: `
|
||||
|
||||
@@ -21,10 +21,10 @@ export function Card({ children, className = '', hover = true, onClick }: CardPr
|
||||
saturate-[180%]
|
||||
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
|
||||
rounded-[var(--radius-2xl)]
|
||||
shadow-[var(--shadow-md)]
|
||||
shadow-[0_2px_8px_var(--shadow-color)] md:shadow-[var(--shadow-md)]
|
||||
border
|
||||
border-[var(--glass-border)]
|
||||
p-6
|
||||
p-4 md:p-6
|
||||
relative
|
||||
${hoverStyles}
|
||||
${className}
|
||||
|
||||
@@ -112,6 +112,22 @@ export const Icons = {
|
||||
</svg>
|
||||
),
|
||||
|
||||
ChevronDown: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
),
|
||||
|
||||
// List & Organization
|
||||
List: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg
|
||||
|
||||
@@ -17,7 +17,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-6 py-4
|
||||
w-full px-4 py-3 md:px-6 md:py-4
|
||||
text-base md:text-[var(--text-color)]
|
||||
bg-[var(--glass-bg)]
|
||||
backdrop-blur-[10px]
|
||||
saturate-[150%]
|
||||
@@ -32,6 +33,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
|
||||
transition-all
|
||||
duration-[var(--transition-fluid)]
|
||||
touch-manipulation
|
||||
${error ? 'border-red-500' : ''}
|
||||
${className}
|
||||
`}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DoubleTapHandler {
|
||||
onDoubleTapLeft: () => void;
|
||||
onDoubleTapRight: () => void;
|
||||
onSingleTap: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling double-tap gestures on mobile devices
|
||||
* Divides the video into left/right zones for skip forward/backward
|
||||
*/
|
||||
export function useDoubleTap({
|
||||
onDoubleTapLeft,
|
||||
onDoubleTapRight,
|
||||
onSingleTap,
|
||||
}: DoubleTapHandler) {
|
||||
const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | null }>({
|
||||
time: 0,
|
||||
side: null,
|
||||
});
|
||||
|
||||
const handleTap = (e: React.TouchEvent<HTMLVideoElement>) => {
|
||||
const currentTime = Date.now();
|
||||
const videoElement = e.currentTarget;
|
||||
const touch = e.touches[0] || e.changedTouches[0];
|
||||
|
||||
if (!touch || !videoElement) return;
|
||||
|
||||
// Calculate touch position relative to video element
|
||||
const rect = videoElement.getBoundingClientRect();
|
||||
const x = touch.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const side = x < width / 2 ? 'left' : 'right';
|
||||
|
||||
const timeDiff = currentTime - lastTapRef.current.time;
|
||||
const sameSide = lastTapRef.current.side === side;
|
||||
|
||||
// Double tap detected (within 300ms on the same side)
|
||||
if (timeDiff < 300 && sameSide) {
|
||||
e.preventDefault();
|
||||
|
||||
if (side === 'left') {
|
||||
onDoubleTapLeft();
|
||||
} else {
|
||||
onDoubleTapRight();
|
||||
}
|
||||
|
||||
// Reset to prevent triple-tap
|
||||
lastTapRef.current = { time: 0, side: null };
|
||||
} else {
|
||||
// Single tap - delay to check for double tap
|
||||
setTimeout(() => {
|
||||
if (Date.now() - currentTime >= 300) {
|
||||
onSingleTap();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
lastTapRef.current = { time: currentTime, side };
|
||||
}
|
||||
};
|
||||
|
||||
return { handleTap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing screen orientation on mobile devices
|
||||
* Auto-rotates to landscape on fullscreen, portrait on exit
|
||||
*/
|
||||
export function useScreenOrientation(isFullscreen: boolean) {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !('screen' in window)) return;
|
||||
|
||||
const handleOrientation = async () => {
|
||||
try {
|
||||
const screen = window.screen as any;
|
||||
|
||||
if (isFullscreen) {
|
||||
// Fullscreen: Lock to landscape
|
||||
if (screen.orientation?.lock) {
|
||||
await screen.orientation.lock('landscape').catch((err: any) => {
|
||||
console.warn('Could not lock orientation:', err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Exit fullscreen: Unlock to allow portrait
|
||||
if (screen.orientation?.unlock) {
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Orientation API not supported:', error);
|
||||
}
|
||||
};
|
||||
|
||||
handleOrientation();
|
||||
|
||||
// Cleanup: Always unlock on unmount
|
||||
return () => {
|
||||
try {
|
||||
const screen = window.screen as any;
|
||||
if (screen.orientation?.unlock) {
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the device is mobile
|
||||
*/
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
) || window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
Reference in New Issue
Block a user