From 9679ccc12c26a7bfb5419c58b2b171bfde4500ef Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Mon, 17 Nov 2025 22:17:24 +0800 Subject: [PATCH] feat: Add skip forward/backward functionality with visual indicators; implement Picture-in-Picture and AirPlay support in CustomVideoPlayer --- app/globals.css | 15 ++ components/player/CustomVideoPlayer.tsx | 199 ++++++++++++++++++++++++ components/ui/Icon.tsx | 68 ++++++++ 3 files changed, 282 insertions(+) diff --git a/app/globals.css b/app/globals.css index 6929bc9..741a1a5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/player/CustomVideoPlayer.tsx b/components/player/CustomVideoPlayer.tsx index 2b91ff5..ddb8be9 100644 --- a/components/player/CustomVideoPlayer.tsx +++ b/components/player/CustomVideoPlayer.tsx @@ -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(null); const speedMenuTimeoutRef = useRef(null); + const skipForwardTimeoutRef = useRef(null); + const skipBackwardTimeoutRef = useRef(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({ )} + {/* Skip Forward Indicator */} + {showSkipForwardIndicator && ( +
+
+ +{skipForwardAmount} +
+
+ )} + + {/* Skip Backward Indicator */} + {showSkipBackwardIndicator && ( +
+
+ -{skipBackwardAmount} +
+
+ )} + {/* Center Play Button (when paused) */} {!isPlaying && !isLoading && (
@@ -380,6 +535,26 @@ export function CustomVideoPlayer({ {isPlaying ? : } + {/* Skip Backward 10s */} + + + {/* Skip Forward 10s */} + + {/* Volume */}
+ )} + + {/* AirPlay */} + {isAirPlaySupported && ( + + )} + {/* Fullscreen */}