mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: Add skip forward/backward functionality with visual indicators; implement Picture-in-Picture and AirPlay support in CustomVideoPlayer
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user