feat: Add skip forward/backward functionality with visual indicators; implement Picture-in-Picture and AirPlay support in CustomVideoPlayer

This commit is contained in:
kuekhaoyang
2025-11-17 22:17:24 +08:00
parent 3fbd77e308
commit 9679ccc12c
3 changed files with 282 additions and 0 deletions
+15
View File
@@ -182,6 +182,17 @@ body.dark,
}
}
@keyframes scale-out {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) translateX(0);
@@ -238,6 +249,10 @@ body.dark,
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.animate-scale-out {
animation: scale-out 0.2s ease-out forwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
+199
View File
@@ -33,12 +33,33 @@ export function CustomVideoPlayer({
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 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 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;
@@ -248,6 +269,118 @@ export function CustomVideoPlayer({
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
if (skipForwardTimeoutRef.current) {
clearTimeout(skipForwardTimeoutRef.current);
}
// Accumulate skip amount
const newSkipAmount = skipForwardAmount + 10;
setSkipForwardAmount(newSkipAmount);
setShowSkipForwardIndicator(true);
setIsSkipForwardAnimatingOut(false);
// Actually skip the video
videoRef.current.currentTime = Math.min(videoRef.current.currentTime + 10, duration);
// Start fade out animation after 200ms (half of original 400ms)
skipForwardTimeoutRef.current = setTimeout(() => {
setIsSkipForwardAnimatingOut(true);
// Hide indicator after animation completes (200ms)
setTimeout(() => {
setShowSkipForwardIndicator(false);
setSkipForwardAmount(0);
setIsSkipForwardAnimatingOut(false);
}, 200);
}, 200);
};
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
if (skipBackwardTimeoutRef.current) {
clearTimeout(skipBackwardTimeoutRef.current);
}
// Accumulate skip amount
const newSkipAmount = skipBackwardAmount + 10;
setSkipBackwardAmount(newSkipAmount);
setShowSkipBackwardIndicator(true);
setIsSkipBackwardAnimatingOut(false);
// Actually skip the video
videoRef.current.currentTime = Math.max(videoRef.current.currentTime - 10, 0);
// Start fade out animation after 200ms (half of original 400ms)
skipBackwardTimeoutRef.current = setTimeout(() => {
setIsSkipBackwardAnimatingOut(true);
// Hide indicator after animation completes (200ms)
setTimeout(() => {
setShowSkipBackwardIndicator(false);
setSkipBackwardAmount(0);
setIsSkipBackwardAnimatingOut(false);
}, 200);
}, 200);
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (skipForwardTimeoutRef.current) {
clearTimeout(skipForwardTimeoutRef.current);
}
if (skipBackwardTimeoutRef.current) {
clearTimeout(skipBackwardTimeoutRef.current);
}
};
}, []);
// Playback speed
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
@@ -326,6 +459,28 @@ export function CustomVideoPlayer({
</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">
@@ -380,6 +535,26 @@ export function CustomVideoPlayer({
{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
@@ -463,6 +638,30 @@ export function CustomVideoPlayer({
)}
</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}
+68
View File
@@ -363,6 +363,74 @@ export const Icons = {
</svg>
),
SkipForward: ({ 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}
>
<polygon points="5 4 15 12 5 20 5 4"/>
<line x1="19" y1="5" x2="19" y2="19"/>
</svg>
),
SkipBack: ({ 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}
>
<polygon points="19 20 9 12 19 4 19 20"/>
<line x1="5" y1="19" x2="5" y2="5"/>
</svg>
),
PictureInPicture: ({ 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}
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<rect x="13" y="10" width="7" height="7" rx="1" ry="1"/>
</svg>
),
Airplay: ({ 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}
>
<path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"/>
<polygon points="12 15 17 21 7 21 12 15"/>
</svg>
),
Check: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}