forked from zhongyidian/robot-shanghai
1080 lines
30 KiB
TypeScript
1080 lines
30 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { cn } from "@/lib/cn";
|
|
import styles from "./VideoPlayer.module.css";
|
|
import type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
|
|
|
|
const PLAYBACK_RATES = [0.5, 1, 1.5, 2] as const;
|
|
const IDLE_HIDE_DELAY = 3000;
|
|
const SEMANTIC_UPDATE_INTERVAL = 200;
|
|
const DRAG_UPDATE_INTERVAL = 100;
|
|
const ACTIVITY_THROTTLE_INTERVAL = 250;
|
|
|
|
type VideoFrameMetadata = {
|
|
mediaTime: number;
|
|
};
|
|
|
|
type VideoWithFrameCallbacks = HTMLVideoElement & {
|
|
requestVideoFrameCallback?: (
|
|
callback: (now: number, metadata: VideoFrameMetadata) => void,
|
|
) => number;
|
|
cancelVideoFrameCallback?: (id: number) => void;
|
|
};
|
|
|
|
type PlayerCallbacks = Pick<
|
|
VideoPlayerProps,
|
|
| "onPlay"
|
|
| "onPause"
|
|
| "onEnded"
|
|
| "onTimeUpdate"
|
|
| "onProgress"
|
|
| "onVolumeChange"
|
|
>;
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function clampTime(time: number, duration: number): number {
|
|
if (!Number.isFinite(time) || !Number.isFinite(duration) || duration <= 0) {
|
|
return 0;
|
|
}
|
|
return Math.max(0, Math.min(duration, time));
|
|
}
|
|
|
|
function isMobileDevice(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
return (
|
|
/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
|
navigator.userAgent,
|
|
) ||
|
|
("ontouchstart" in window && navigator.maxTouchPoints > 0)
|
|
);
|
|
}
|
|
|
|
export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|
(
|
|
{
|
|
src,
|
|
poster,
|
|
autoPlay = false,
|
|
loop = false,
|
|
muted: initialMuted = false,
|
|
preload = "metadata",
|
|
playbackRate: initialRate = 1,
|
|
mode = "window",
|
|
allowFullscreenToggle = true,
|
|
className,
|
|
onPlay,
|
|
onPause,
|
|
onEnded,
|
|
onTimeUpdate,
|
|
onProgress,
|
|
onVolumeChange,
|
|
onPlaybackRateChange,
|
|
onFullscreenChange,
|
|
},
|
|
ref,
|
|
) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
const progressBufferedRef = useRef<HTMLDivElement>(null);
|
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
const miniProgressBarRef = useRef<HTMLDivElement>(null);
|
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const isPlayingRef = useRef(false);
|
|
const showControlsRef = useRef(true);
|
|
const durationRef = useRef(0);
|
|
const currentTimeRef = useRef(0);
|
|
const progressWidthRef = useRef(0);
|
|
const lastSemanticUpdateRef = useRef(0);
|
|
const lastDragUpdateRef = useRef(0);
|
|
const lastActivityRef = useRef(0);
|
|
const callbacksRef = useRef<PlayerCallbacks>({});
|
|
const dragActiveRef = useRef(false);
|
|
const dragWasPlayingRef = useRef(false);
|
|
const dragRectRef = useRef<DOMRect | null>(null);
|
|
const dragPreviewTimeRef = useRef(0);
|
|
const dragFrameRef = useRef<number | null>(null);
|
|
const activePointerIdRef = useRef<number | null>(null);
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [volume, setVolumeState] = useState(1);
|
|
const [isMuted, setIsMuted] = useState(initialMuted);
|
|
const [playbackRate, setPlaybackRateState] = useState(initialRate);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [hasPlaybackError, setHasPlaybackError] = useState(false);
|
|
const [showCenterButton, setShowCenterButton] = useState(false);
|
|
const [isFullscreen, setIsFullscreen] = useState(mode === "fullscreen");
|
|
const isMobile = isMobileDevice();
|
|
const supportsPointerEvents =
|
|
typeof window !== "undefined" && "PointerEvent" in window;
|
|
|
|
callbacksRef.current = {
|
|
onPlay,
|
|
onPause,
|
|
onEnded,
|
|
onTimeUpdate,
|
|
onProgress,
|
|
onVolumeChange,
|
|
};
|
|
|
|
const applyVisualProgress = useCallback((time: number) => {
|
|
const safeTime = clampTime(time, durationRef.current);
|
|
const ratio =
|
|
durationRef.current > 0 ? safeTime / durationRef.current : 0;
|
|
const scale = `scaleX(${ratio})`;
|
|
if (progressBarRef.current)
|
|
progressBarRef.current.style.transform = scale;
|
|
if (miniProgressBarRef.current) {
|
|
miniProgressBarRef.current.style.transform = scale;
|
|
}
|
|
if (progressThumbRef.current) {
|
|
const offset = ratio * progressWidthRef.current;
|
|
progressThumbRef.current.style.transform = `translate3d(${offset}px, -50%, 0)`;
|
|
}
|
|
}, []);
|
|
|
|
const applyBufferedProgress = useCallback((ratio: number) => {
|
|
const safeRatio = Number.isFinite(ratio)
|
|
? Math.max(0, Math.min(1, ratio))
|
|
: 0;
|
|
if (progressBufferedRef.current) {
|
|
progressBufferedRef.current.style.transform = `scaleX(${safeRatio})`;
|
|
}
|
|
}, []);
|
|
|
|
const commitCurrentTime = useCallback((time: number, force = false) => {
|
|
const safeTime = clampTime(time, durationRef.current);
|
|
currentTimeRef.current = safeTime;
|
|
const now = performance.now();
|
|
if (
|
|
force ||
|
|
now - lastSemanticUpdateRef.current >= SEMANTIC_UPDATE_INTERVAL
|
|
) {
|
|
lastSemanticUpdateRef.current = now;
|
|
setCurrentTime(safeTime);
|
|
}
|
|
return safeTime;
|
|
}, []);
|
|
|
|
const syncProgress = useCallback(
|
|
(time: number, forceSemantic = false) => {
|
|
const safeTime = commitCurrentTime(time, forceSemantic);
|
|
applyVisualProgress(safeTime);
|
|
return safeTime;
|
|
},
|
|
[applyVisualProgress, commitCurrentTime],
|
|
);
|
|
|
|
const clearIdleTimer = useCallback(() => {
|
|
if (idleTimerRef.current) {
|
|
clearTimeout(idleTimerRef.current);
|
|
idleTimerRef.current = undefined;
|
|
}
|
|
}, []);
|
|
|
|
const showPlayerControls = useCallback(() => {
|
|
showControlsRef.current = true;
|
|
setShowControls(true);
|
|
}, []);
|
|
|
|
const resetIdleTimer = useCallback(() => {
|
|
showPlayerControls();
|
|
clearIdleTimer();
|
|
idleTimerRef.current = setTimeout(() => {
|
|
if (isPlayingRef.current && !dragActiveRef.current) {
|
|
showControlsRef.current = false;
|
|
setShowControls(false);
|
|
}
|
|
}, IDLE_HIDE_DELAY);
|
|
}, [clearIdleTimer, showPlayerControls]);
|
|
|
|
const handlePointerActivity = useCallback(() => {
|
|
const now = performance.now();
|
|
if (
|
|
!showControlsRef.current ||
|
|
now - lastActivityRef.current >= ACTIVITY_THROTTLE_INTERVAL
|
|
) {
|
|
lastActivityRef.current = now;
|
|
resetIdleTimer();
|
|
}
|
|
}, [resetIdleTimer]);
|
|
|
|
const seekTo = useCallback(
|
|
(time: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return 0;
|
|
const safeTime = syncProgress(time, true);
|
|
video.currentTime = safeTime;
|
|
return safeTime;
|
|
},
|
|
[syncProgress],
|
|
);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
play: () => videoRef.current?.play(),
|
|
pause: () => videoRef.current?.pause(),
|
|
seek: (time: number) => {
|
|
seekTo(time);
|
|
},
|
|
setVolume: (value: number) => {
|
|
if (videoRef.current) videoRef.current.volume = value;
|
|
},
|
|
setPlaybackRate: (rate: number) => {
|
|
if (videoRef.current) videoRef.current.playbackRate = rate;
|
|
},
|
|
getCurrentTime: () => videoRef.current?.currentTime ?? 0,
|
|
getDuration: () => videoRef.current?.duration ?? 0,
|
|
}));
|
|
|
|
const showManualPlay = useCallback(() => {
|
|
setIsLoading(false);
|
|
setShowCenterButton(true);
|
|
showPlayerControls();
|
|
}, [showPlayerControls]);
|
|
|
|
const retryPlayback = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
setHasPlaybackError(false);
|
|
setIsLoading(true);
|
|
video.load();
|
|
video.play().catch(showManualPlay);
|
|
}, [showManualPlay]);
|
|
|
|
const togglePlay = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
if (video.paused) video.play().catch(showManualPlay);
|
|
else video.pause();
|
|
resetIdleTimer();
|
|
}, [resetIdleTimer, showManualPlay]);
|
|
|
|
const toggleMute = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.muted = !video.muted;
|
|
setIsMuted(video.muted);
|
|
resetIdleTimer();
|
|
}, [resetIdleTimer]);
|
|
|
|
const handleVolumeChange = useCallback(
|
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const newVolume = Number.parseFloat(event.target.value);
|
|
video.volume = newVolume;
|
|
if (newVolume > 0 && video.muted) video.muted = false;
|
|
setVolumeState(newVolume);
|
|
setIsMuted(video.muted);
|
|
resetIdleTimer();
|
|
},
|
|
[resetIdleTimer],
|
|
);
|
|
|
|
const cyclePlaybackRate = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const currentIndex = PLAYBACK_RATES.indexOf(
|
|
playbackRate as (typeof PLAYBACK_RATES)[number],
|
|
);
|
|
const nextIndex = (currentIndex + 1) % PLAYBACK_RATES.length;
|
|
const nextRate = PLAYBACK_RATES[nextIndex];
|
|
video.playbackRate = nextRate;
|
|
setPlaybackRateState(nextRate);
|
|
onPlaybackRateChange?.(nextRate);
|
|
resetIdleTimer();
|
|
}, [playbackRate, onPlaybackRateChange, resetIdleTimer]);
|
|
|
|
const toggleFullscreen = useCallback(() => {
|
|
const next = !isFullscreen;
|
|
setIsFullscreen(next);
|
|
onFullscreenChange?.(next);
|
|
resetIdleTimer();
|
|
}, [isFullscreen, onFullscreenChange, resetIdleTimer]);
|
|
|
|
const getDragTime = useCallback((clientX: number) => {
|
|
const rect = dragRectRef.current;
|
|
if (!rect || rect.width <= 0) return 0;
|
|
const ratio = Math.max(
|
|
0,
|
|
Math.min(1, (clientX - rect.left) / rect.width),
|
|
);
|
|
return ratio * durationRef.current;
|
|
}, []);
|
|
|
|
const flushDragPreview = useCallback(() => {
|
|
if (dragFrameRef.current !== null) {
|
|
cancelAnimationFrame(dragFrameRef.current);
|
|
dragFrameRef.current = null;
|
|
}
|
|
const previewTime = dragPreviewTimeRef.current;
|
|
applyVisualProgress(previewTime);
|
|
commitCurrentTime(previewTime, true);
|
|
}, [applyVisualProgress, commitCurrentTime]);
|
|
|
|
const queueDragPreview = useCallback(
|
|
(clientX: number, forceSemantic = false) => {
|
|
if (!dragActiveRef.current) return;
|
|
dragPreviewTimeRef.current = getDragTime(clientX);
|
|
if (dragFrameRef.current !== null) return;
|
|
dragFrameRef.current = requestAnimationFrame((now) => {
|
|
dragFrameRef.current = null;
|
|
const previewTime = dragPreviewTimeRef.current;
|
|
applyVisualProgress(previewTime);
|
|
if (
|
|
forceSemantic ||
|
|
now - lastDragUpdateRef.current >= DRAG_UPDATE_INTERVAL
|
|
) {
|
|
lastDragUpdateRef.current = now;
|
|
commitCurrentTime(previewTime, true);
|
|
}
|
|
});
|
|
},
|
|
[applyVisualProgress, commitCurrentTime, getDragTime],
|
|
);
|
|
|
|
const beginDrag = useCallback(
|
|
(clientX: number) => {
|
|
const video = videoRef.current;
|
|
const progress = progressRef.current;
|
|
if (
|
|
!video ||
|
|
!progress ||
|
|
!Number.isFinite(durationRef.current) ||
|
|
durationRef.current <= 0
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
dragRectRef.current = progress.getBoundingClientRect();
|
|
dragActiveRef.current = true;
|
|
dragWasPlayingRef.current = !video.paused;
|
|
video.pause();
|
|
setIsDragging(true);
|
|
resetIdleTimer();
|
|
queueDragPreview(clientX, true);
|
|
return true;
|
|
},
|
|
[queueDragPreview, resetIdleTimer],
|
|
);
|
|
|
|
const finishDrag = useCallback(
|
|
(clientX?: number) => {
|
|
if (!dragActiveRef.current) return;
|
|
if (clientX !== undefined) {
|
|
dragPreviewTimeRef.current = getDragTime(clientX);
|
|
}
|
|
flushDragPreview();
|
|
const video = videoRef.current;
|
|
if (video) video.currentTime = dragPreviewTimeRef.current;
|
|
dragActiveRef.current = false;
|
|
dragRectRef.current = null;
|
|
activePointerIdRef.current = null;
|
|
setIsDragging(false);
|
|
if (dragWasPlayingRef.current) video?.play().catch(() => {});
|
|
dragWasPlayingRef.current = false;
|
|
resetIdleTimer();
|
|
},
|
|
[flushDragPreview, getDragTime, resetIdleTimer],
|
|
);
|
|
|
|
const cancelDrag = useCallback(() => {
|
|
if (!dragActiveRef.current) return;
|
|
if (dragFrameRef.current !== null) {
|
|
cancelAnimationFrame(dragFrameRef.current);
|
|
dragFrameRef.current = null;
|
|
}
|
|
const video = videoRef.current;
|
|
if (video) syncProgress(video.currentTime, true);
|
|
dragActiveRef.current = false;
|
|
dragRectRef.current = null;
|
|
activePointerIdRef.current = null;
|
|
setIsDragging(false);
|
|
if (dragWasPlayingRef.current) video?.play().catch(() => {});
|
|
dragWasPlayingRef.current = false;
|
|
resetIdleTimer();
|
|
}, [resetIdleTimer, syncProgress]);
|
|
|
|
const handleProgressPointerDown = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (
|
|
!event.isPrimary ||
|
|
(event.pointerType === "mouse" && event.button !== 0)
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (!beginDrag(event.clientX)) return;
|
|
activePointerIdRef.current = event.pointerId;
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
},
|
|
[beginDrag],
|
|
);
|
|
|
|
const handleProgressPointerMove = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (activePointerIdRef.current !== event.pointerId) return;
|
|
queueDragPreview(event.clientX);
|
|
},
|
|
[queueDragPreview],
|
|
);
|
|
|
|
const handleProgressPointerUp = useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (activePointerIdRef.current !== event.pointerId) return;
|
|
finishDrag(event.clientX);
|
|
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
}
|
|
},
|
|
[finishDrag],
|
|
);
|
|
|
|
const handleProgressKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
let nextTime = video.currentTime;
|
|
switch (event.key) {
|
|
case "ArrowLeft":
|
|
case "ArrowDown":
|
|
nextTime -= 5;
|
|
break;
|
|
case "ArrowRight":
|
|
case "ArrowUp":
|
|
nextTime += 5;
|
|
break;
|
|
case "Home":
|
|
nextTime = 0;
|
|
break;
|
|
case "End":
|
|
nextTime = durationRef.current;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
seekTo(nextTime);
|
|
resetIdleTimer();
|
|
},
|
|
[resetIdleTimer, seekTo],
|
|
);
|
|
|
|
const skipTime = useCallback(
|
|
(seconds: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
seekTo(video.currentTime + seconds);
|
|
resetIdleTimer();
|
|
},
|
|
[resetIdleTimer, seekTo],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handlePlay = () => {
|
|
isPlayingRef.current = true;
|
|
setIsPlaying(true);
|
|
setShowCenterButton(false);
|
|
resetIdleTimer();
|
|
callbacksRef.current.onPlay?.();
|
|
};
|
|
const handlePause = () => {
|
|
isPlayingRef.current = false;
|
|
setIsPlaying(false);
|
|
setShowCenterButton(true);
|
|
showPlayerControls();
|
|
clearIdleTimer();
|
|
syncProgress(video.currentTime, true);
|
|
callbacksRef.current.onPause?.();
|
|
};
|
|
const handleEnded = () => {
|
|
isPlayingRef.current = false;
|
|
setIsPlaying(false);
|
|
setShowCenterButton(true);
|
|
showPlayerControls();
|
|
clearIdleTimer();
|
|
syncProgress(video.currentTime, true);
|
|
callbacksRef.current.onEnded?.();
|
|
};
|
|
const handleTimeUpdate = () => {
|
|
if (!dragActiveRef.current) syncProgress(video.currentTime);
|
|
callbacksRef.current.onTimeUpdate?.(video.currentTime);
|
|
};
|
|
const handleLoadedMetadata = () => {
|
|
const nextDuration = Number.isFinite(video.duration)
|
|
? video.duration
|
|
: 0;
|
|
durationRef.current = nextDuration;
|
|
setDuration(nextDuration);
|
|
setVolumeState(video.volume);
|
|
setIsMuted(video.muted);
|
|
syncProgress(video.currentTime, true);
|
|
};
|
|
const handleProgress = () => {
|
|
if (video.buffered.length > 0 && durationRef.current > 0) {
|
|
const end = video.buffered.end(video.buffered.length - 1);
|
|
applyBufferedProgress(end / durationRef.current);
|
|
} else {
|
|
applyBufferedProgress(0);
|
|
}
|
|
callbacksRef.current.onProgress?.(video.buffered);
|
|
};
|
|
const handleVolume = () => {
|
|
setVolumeState(video.volume);
|
|
setIsMuted(video.muted);
|
|
callbacksRef.current.onVolumeChange?.(video.volume, video.muted);
|
|
};
|
|
const handleWaiting = () => setIsLoading(true);
|
|
const handleCanPlay = () => {
|
|
setIsLoading(false);
|
|
setHasPlaybackError(false);
|
|
};
|
|
const handleError = () => {
|
|
setIsLoading(false);
|
|
setHasPlaybackError(true);
|
|
setShowCenterButton(false);
|
|
showPlayerControls();
|
|
};
|
|
const handleSeeked = () => {
|
|
if (!dragActiveRef.current) syncProgress(video.currentTime, true);
|
|
};
|
|
|
|
video.addEventListener("play", handlePlay);
|
|
video.addEventListener("pause", handlePause);
|
|
video.addEventListener("ended", handleEnded);
|
|
video.addEventListener("timeupdate", handleTimeUpdate);
|
|
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
|
video.addEventListener("progress", handleProgress);
|
|
video.addEventListener("volumechange", handleVolume);
|
|
video.addEventListener("waiting", handleWaiting);
|
|
video.addEventListener("canplay", handleCanPlay);
|
|
video.addEventListener("error", handleError);
|
|
video.addEventListener("seeked", handleSeeked);
|
|
|
|
return () => {
|
|
video.removeEventListener("play", handlePlay);
|
|
video.removeEventListener("pause", handlePause);
|
|
video.removeEventListener("ended", handleEnded);
|
|
video.removeEventListener("timeupdate", handleTimeUpdate);
|
|
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
|
video.removeEventListener("progress", handleProgress);
|
|
video.removeEventListener("volumechange", handleVolume);
|
|
video.removeEventListener("waiting", handleWaiting);
|
|
video.removeEventListener("canplay", handleCanPlay);
|
|
video.removeEventListener("error", handleError);
|
|
video.removeEventListener("seeked", handleSeeked);
|
|
};
|
|
}, [
|
|
applyBufferedProgress,
|
|
clearIdleTimer,
|
|
resetIdleTimer,
|
|
showPlayerControls,
|
|
syncProgress,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current as VideoWithFrameCallbacks | null;
|
|
if (!video || !isPlaying || isDragging) return;
|
|
|
|
let active = true;
|
|
let frameId: number | null = null;
|
|
let frameKind: "video" | "animation" | null = null;
|
|
|
|
const cancelFrame = () => {
|
|
if (frameId === null) return;
|
|
if (frameKind === "video" && video.cancelVideoFrameCallback) {
|
|
video.cancelVideoFrameCallback(frameId);
|
|
} else if (frameKind === "animation") {
|
|
cancelAnimationFrame(frameId);
|
|
}
|
|
frameId = null;
|
|
frameKind = null;
|
|
};
|
|
|
|
const scheduleFrame = () => {
|
|
if (
|
|
!active ||
|
|
document.hidden ||
|
|
video.paused ||
|
|
video.ended ||
|
|
frameId !== null
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (video.requestVideoFrameCallback) {
|
|
frameKind = "video";
|
|
frameId = video.requestVideoFrameCallback((_now, metadata) => {
|
|
frameId = null;
|
|
frameKind = null;
|
|
const mediaTime = Number.isFinite(metadata.mediaTime)
|
|
? metadata.mediaTime
|
|
: video.currentTime;
|
|
syncProgress(mediaTime);
|
|
scheduleFrame();
|
|
});
|
|
} else {
|
|
frameKind = "animation";
|
|
frameId = requestAnimationFrame(() => {
|
|
frameId = null;
|
|
frameKind = null;
|
|
syncProgress(video.currentTime);
|
|
scheduleFrame();
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
cancelFrame();
|
|
if (!document.hidden) scheduleFrame();
|
|
};
|
|
|
|
scheduleFrame();
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () => {
|
|
active = false;
|
|
cancelFrame();
|
|
document.removeEventListener(
|
|
"visibilitychange",
|
|
handleVisibilityChange,
|
|
);
|
|
};
|
|
}, [isDragging, isPlaying, syncProgress]);
|
|
|
|
useEffect(() => {
|
|
const progress = progressRef.current;
|
|
if (!progress) return;
|
|
const updateWidth = () => {
|
|
progressWidthRef.current = progress.clientWidth;
|
|
applyVisualProgress(currentTimeRef.current);
|
|
};
|
|
updateWidth();
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
const observer = new ResizeObserver(updateWidth);
|
|
observer.observe(progress);
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
window.addEventListener("resize", updateWidth);
|
|
return () => window.removeEventListener("resize", updateWidth);
|
|
}, [applyVisualProgress]);
|
|
|
|
useEffect(() => {
|
|
if (supportsPointerEvents) return;
|
|
|
|
const handleMouseMove = (event: MouseEvent) => {
|
|
if (dragActiveRef.current) queueDragPreview(event.clientX);
|
|
};
|
|
const handleMouseUp = (event: MouseEvent) => {
|
|
if (dragActiveRef.current) finishDrag(event.clientX);
|
|
};
|
|
const handleTouchMove = (event: TouchEvent) => {
|
|
if (!dragActiveRef.current) return;
|
|
event.preventDefault();
|
|
const touch = event.touches[0];
|
|
if (touch) queueDragPreview(touch.clientX);
|
|
};
|
|
const handleTouchEnd = (event: TouchEvent) => {
|
|
if (!dragActiveRef.current) return;
|
|
const touch = event.changedTouches[0];
|
|
finishDrag(touch?.clientX);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
document.addEventListener("touchmove", handleTouchMove, {
|
|
passive: false,
|
|
});
|
|
document.addEventListener("touchend", handleTouchEnd);
|
|
document.addEventListener("touchcancel", cancelDrag);
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
document.removeEventListener("touchmove", handleTouchMove);
|
|
document.removeEventListener("touchend", handleTouchEnd);
|
|
document.removeEventListener("touchcancel", cancelDrag);
|
|
};
|
|
}, [cancelDrag, finishDrag, queueDragPreview, supportsPointerEvents]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearIdleTimer();
|
|
if (dragFrameRef.current !== null) {
|
|
cancelAnimationFrame(dragFrameRef.current);
|
|
}
|
|
};
|
|
}, [clearIdleTimer]);
|
|
|
|
const handlePlayerKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
switch (event.key) {
|
|
case " ":
|
|
event.preventDefault();
|
|
togglePlay();
|
|
break;
|
|
case "ArrowLeft":
|
|
event.preventDefault();
|
|
skipTime(-5);
|
|
break;
|
|
case "ArrowRight":
|
|
event.preventDefault();
|
|
skipTime(5);
|
|
break;
|
|
case "ArrowUp":
|
|
event.preventDefault();
|
|
video.volume = Math.min(1, video.volume + 0.1);
|
|
break;
|
|
case "ArrowDown":
|
|
event.preventDefault();
|
|
video.volume = Math.max(0, video.volume - 0.1);
|
|
break;
|
|
case "f":
|
|
if (allowFullscreenToggle) {
|
|
event.preventDefault();
|
|
toggleFullscreen();
|
|
}
|
|
break;
|
|
case "m":
|
|
event.preventDefault();
|
|
toggleMute();
|
|
break;
|
|
}
|
|
},
|
|
[
|
|
allowFullscreenToggle,
|
|
skipTime,
|
|
toggleFullscreen,
|
|
toggleMute,
|
|
togglePlay,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
styles.player,
|
|
isFullscreen ? styles.playerFullscreen : styles.playerWindow,
|
|
className,
|
|
)}
|
|
role="application"
|
|
aria-label="视频播放器"
|
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: 播放器应用区域需要接收键盘快捷键
|
|
tabIndex={0}
|
|
onKeyDown={handlePlayerKeyDown}
|
|
onPointerMove={handlePointerActivity}
|
|
onPointerDown={resetIdleTimer}
|
|
onMouseMove={supportsPointerEvents ? undefined : handlePointerActivity}
|
|
onClick={(event) => {
|
|
if (
|
|
event.target === videoRef.current ||
|
|
event.target === containerRef.current
|
|
) {
|
|
togglePlay();
|
|
}
|
|
}}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
poster={poster}
|
|
preload={preload}
|
|
loop={loop}
|
|
muted={initialMuted}
|
|
playsInline
|
|
webkit-playsinline="true"
|
|
x5-playsinline="true"
|
|
x5-video-player-type="h5"
|
|
x5-video-player-fullscreen="false"
|
|
className={styles.video}
|
|
onLoadedMetadata={() => {
|
|
if (autoPlay) videoRef.current?.play().catch(showManualPlay);
|
|
if (initialRate !== 1 && videoRef.current) {
|
|
videoRef.current.playbackRate = initialRate;
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{isLoading && !hasPlaybackError && (
|
|
<div className={styles.loading}>
|
|
<div className={styles.spinner} />
|
|
</div>
|
|
)}
|
|
|
|
{hasPlaybackError && (
|
|
<div
|
|
role="alert"
|
|
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-6 bg-black/75 text-white"
|
|
>
|
|
<p className="text-3xl font-medium">视频加载失败</p>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg bg-white px-8 py-3 text-2xl text-black hover:bg-gray-200"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
retryPlayback();
|
|
}}
|
|
>
|
|
重新加载
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showCenterButton && !isLoading && !hasPlaybackError && (
|
|
<button
|
|
type="button"
|
|
className={styles.centerPlayButton}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
togglePlay();
|
|
}}
|
|
aria-label={isPlaying ? "暂停" : "播放"}
|
|
>
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="white"
|
|
aria-hidden="true"
|
|
>
|
|
{isPlaying ? (
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
) : (
|
|
<path d="M8 5v14l11-7z" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
styles.controls,
|
|
!showControls && styles.controlsHidden,
|
|
)}
|
|
>
|
|
<div
|
|
ref={progressRef}
|
|
className={cn(
|
|
styles.progressContainer,
|
|
isDragging && styles.progressDragging,
|
|
)}
|
|
role="slider"
|
|
aria-label="播放进度"
|
|
aria-valuemin={0}
|
|
aria-valuemax={Math.round(duration)}
|
|
aria-valuenow={Math.round(currentTime)}
|
|
aria-valuetext={`${formatTime(currentTime)} / ${formatTime(duration)}`}
|
|
aria-disabled={duration <= 0}
|
|
tabIndex={0}
|
|
onKeyDown={handleProgressKeyDown}
|
|
onPointerDown={
|
|
supportsPointerEvents ? handleProgressPointerDown : undefined
|
|
}
|
|
onPointerMove={
|
|
supportsPointerEvents ? handleProgressPointerMove : undefined
|
|
}
|
|
onPointerUp={
|
|
supportsPointerEvents ? handleProgressPointerUp : undefined
|
|
}
|
|
onPointerCancel={supportsPointerEvents ? cancelDrag : undefined}
|
|
onLostPointerCapture={
|
|
supportsPointerEvents ? () => finishDrag() : undefined
|
|
}
|
|
onMouseDown={
|
|
!supportsPointerEvents
|
|
? (event) => {
|
|
event.preventDefault();
|
|
beginDrag(event.clientX);
|
|
}
|
|
: undefined
|
|
}
|
|
onTouchStart={
|
|
!supportsPointerEvents
|
|
? (event) => {
|
|
event.preventDefault();
|
|
const touch = event.touches[0];
|
|
if (touch) beginDrag(touch.clientX);
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<div
|
|
ref={progressBufferedRef}
|
|
className={styles.progressBuffered}
|
|
/>
|
|
<div ref={progressBarRef} className={styles.progressBar} />
|
|
<div ref={progressThumbRef} className={styles.progressThumb} />
|
|
</div>
|
|
|
|
<div className={styles.controlRow}>
|
|
<button
|
|
type="button"
|
|
className={styles.controlButton}
|
|
onClick={togglePlay}
|
|
aria-label={isPlaying ? "暂停" : "播放"}
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
{isPlaying ? (
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
) : (
|
|
<path d="M8 5v14l11-7z" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.controlButton}
|
|
onClick={() => skipTime(-5)}
|
|
aria-label="快退 5 秒"
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.controlButton}
|
|
onClick={() => skipTime(5)}
|
|
aria-label="快进 5 秒"
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<span className={styles.timeDisplay}>
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.controlButton}
|
|
onClick={toggleMute}
|
|
aria-label={isMuted ? "取消静音" : "静音"}
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
{isMuted || volume === 0 ? (
|
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
|
) : volume < 0.5 ? (
|
|
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z" />
|
|
) : (
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
|
|
{!isMobile && (
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
value={isMuted ? 0 : volume}
|
|
onChange={handleVolumeChange}
|
|
className={styles.volumeSlider}
|
|
aria-label="音量"
|
|
/>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.rateSelector}
|
|
onClick={cyclePlaybackRate}
|
|
aria-label="切换播放速度"
|
|
>
|
|
{playbackRate}x
|
|
</button>
|
|
|
|
{allowFullscreenToggle && (
|
|
<button
|
|
type="button"
|
|
className={styles.controlButton}
|
|
onClick={toggleFullscreen}
|
|
aria-label={isFullscreen ? "退出全屏" : "全屏"}
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
{isFullscreen ? (
|
|
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
|
) : (
|
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
styles.miniProgress,
|
|
(showControls || !isPlaying) && styles.miniProgressHidden,
|
|
)}
|
|
>
|
|
<div ref={miniProgressBarRef} className={styles.miniProgressBar} />
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
VideoPlayer.displayName = "VideoPlayer";
|