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:
kuekhaoyang
2025-11-18 12:40:50 +08:00
parent c9174b11ab
commit a7480fb47a
16 changed files with 1622 additions and 856 deletions
+12
View File
@@ -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
View File
@@ -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>
+13 -788
View File
@@ -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} />;
}
+801
View File
@@ -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>
);
}
+6 -6
View File
@@ -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 && (
+477
View File
@@ -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>
);
}
+6 -6
View File
@@ -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>
+5 -4
View File
@@ -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>
+97 -35
View File
@@ -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>
+37 -4
View File
@@ -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}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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: `
+2 -2
View File
@@ -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}
+16
View File
@@ -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
+3 -1
View File
@@ -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}
`}
+135
View File
@@ -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;
}