Files
robot-shanghai/src/components/common/Video/VideoPlayer.tsx
T
2026-09-08 14:19:44 +08:00

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";