You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

463 lines
16 KiB

"use client";
import { Popover } from "antd";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { preventVideoDoubleClick } from "@/hooks/usePreventVideoFullscreen";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
interface CustomVideoPlayerProps {
src: string;
poster?: string | null;
selected?: boolean;
isRunning?: boolean;
dimensions?: { width: number; height: number } | null;
className?: string;
videoClassName?: string;
loop?: boolean;
muted?: boolean;
captureTitle?: string;
onLoadedMetadata?: (metadata: {
duration: number | null;
dimensions: { width: number; height: number } | null;
}) => void;
onCaptureFrame?: (frame: { time: number }) => Promise<void> | void;
onError?: () => void;
}
type CaptureFrameMode = "first" | "last" | "current";
const compactPopoverStyles = {
container: {
padding: 0,
borderRadius: 8,
overflow: "hidden",
background: "rgba(0,0,0,0.28)",
boxShadow: "0 8px 22px rgba(0,0,0,0.16)",
minWidth: 0,
},
};
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) return "0:00";
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${minutes}:${remaining}`;
}
export function CustomVideoPlayer({
src,
poster,
selected = false,
isRunning = false,
dimensions: _dimensions,
className = "",
videoClassName = "",
loop = true,
muted = true,
captureTitle = "截帧",
onLoadedMetadata,
onCaptureFrame,
onError,
}: CustomVideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isManuallyPaused, setIsManuallyPaused] = useState(false);
const [isScrubbing, setIsScrubbing] = useState(false);
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [hasLoadedFrame, setHasLoadedFrame] = useState(false);
const [volume, setVolume] = useState(muted ? 0 : 1);
const [isMuted, setIsMuted] = useState(muted);
const shouldShowPoster = Boolean(
poster && (isRunning || (!isHovered && !selected && !isManuallyPaused && !isScrubbing))
);
const shouldMountSrc = Boolean(
!isRunning && (!poster || isHovered || selected || isManuallyPaused || isScrubbing || isPlaying)
);
const playbackUrl = useVideoBlobUrl(shouldMountSrc ? src : null);
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
const pauseVideo = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
setIsPlaying(false);
}, []);
const playVideo = useCallback(() => {
const video = videoRef.current;
if (!video || isRunning) return;
video.play()
.then(() => {
setHasLoadedFrame(true);
setIsPlaying(true);
})
.catch(() => {
setIsPlaying(false);
});
}, [isRunning]);
useEffect(() => {
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setIsManuallyPaused(false);
setIsScrubbing(false);
setHasLoadedFrame(false);
}, [src]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = Math.min(1, Math.max(0, volume));
video.muted = isMuted || volume === 0;
}, [isMuted, volume]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isRunning || !shouldMountSrc) {
video.pause();
try {
video.currentTime = 0;
} catch {
// Metadata may not be ready yet.
}
setCurrentTime(0);
setIsPlaying(false);
setHasLoadedFrame(false);
return;
}
if (isScrubbing || isManuallyPaused) {
video.pause();
setIsPlaying(false);
return;
}
if (isHovered || selected) {
playVideo();
return;
}
video.pause();
try {
video.currentTime = 0;
} catch {
// Metadata may not be ready yet.
}
setCurrentTime(0);
setIsPlaying(false);
setHasLoadedFrame(false);
}, [isHovered, isManuallyPaused, isRunning, isScrubbing, playVideo, selected, shouldMountSrc, playbackUrl]);
const seekToRatio = useCallback((clientX: number, target: HTMLElement) => {
const video = videoRef.current;
if (!video || duration <= 0) return;
const rect = target.getBoundingClientRect();
const ratio = rect.width > 0 ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
const nextTime = ratio * duration;
video.currentTime = nextTime;
setCurrentTime(nextTime);
setHasLoadedFrame(true);
}, [duration]);
const handleProgressPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setIsScrubbing(true);
setIsManuallyPaused(true);
pauseVideo();
seekToRatio(event.clientX, event.currentTarget);
}, [pauseVideo, seekToRatio]);
const handleProgressPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
event.preventDefault();
event.stopPropagation();
seekToRatio(event.clientX, event.currentTarget);
}, [isScrubbing, seekToRatio]);
const handleProgressPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
event.preventDefault();
event.stopPropagation();
seekToRatio(event.clientX, event.currentTarget);
setIsScrubbing(false);
setIsManuallyPaused(true);
pauseVideo();
}, [isScrubbing, pauseVideo, seekToRatio]);
const handleTogglePlayback = useCallback((event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (isRunning) return;
if (isPlaying) {
setIsManuallyPaused(true);
pauseVideo();
return;
}
setIsManuallyPaused(false);
playVideo();
}, [isPlaying, isRunning, pauseVideo, playVideo]);
const handleVolumeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
event.stopPropagation();
const nextVolume = Number(event.currentTarget.value) / 100;
setVolume(nextVolume);
setIsMuted(nextVolume === 0);
}, []);
const handleCaptureFrame = useCallback(async (mode: CaptureFrameMode, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const video = videoRef.current;
if (!video || !onCaptureFrame || isCaptureLoading) return;
setIsCaptureLoading(true);
try {
const videoDuration = Number.isFinite(video.duration) ? video.duration : duration;
const time = mode === "first"
? 0.001
: mode === "last"
? Math.max(0, videoDuration - 0.1)
: video.currentTime;
await onCaptureFrame({ time });
} finally {
setIsCaptureLoading(false);
}
}, [duration, isCaptureLoading, onCaptureFrame]);
const playPauseLabel = useMemo(() => isPlaying ? "暂停" : "播放", [isPlaying]);
const volumeLabel = useMemo(() => {
if (isMuted || volume === 0) return "音量 0%";
return `音量 ${Math.round(volume * 100)}%`;
}, [isMuted, volume]);
const volumePopoverContent = useMemo(() => (
<div
className="flex flex-col items-center gap-1.5 px-1.5 py-2 text-white"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<span className="text-[10px] tabular-nums text-white/80">{Math.round(volume * 100)}</span>
<div className="relative flex h-24 w-5 items-center justify-center">
<div
className="absolute bottom-2 left-1/2 w-1 -translate-x-1/2 rounded-full bg-white"
style={{ height: `${Math.max(4, Math.round(volume * 80))}px` }}
/>
<input
aria-label="音量"
type="range"
min={0}
max={100}
value={Math.round(volume * 100)}
onChange={handleVolumeChange}
className="relative h-1 w-20 -rotate-90 cursor-pointer opacity-0"
/>
</div>
</div>
), [handleVolumeChange, volume]);
const capturePopoverContent = useMemo(() => (
<div className="overflow-hidden py-1">
{[
["first", "截取首帧"],
["last", "截取尾帧"],
["current", "截取当前帧"],
].map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={(event) => handleCaptureFrame(mode as CaptureFrameMode, event)}
disabled={isCaptureLoading || isRunning || !hasLoadedFrame}
className="block w-full whitespace-nowrap px-3 py-2 text-left text-xs text-white/90 transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
>
{label}
</button>
))}
</div>
), [handleCaptureFrame, hasLoadedFrame, isCaptureLoading, isRunning]);
return (
<div
className={`relative h-full w-full overflow-hidden ${className}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
>
<video
ref={videoRef}
src={playbackUrl ?? undefined}
poster={poster ?? undefined}
className={videoClassName}
preload={poster ? "none" : "metadata"}
loop={loop}
muted={muted}
playsInline
disablePictureInPicture
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
onLoadedMetadata={(event) => {
const video = event.currentTarget;
const nextDuration = Number.isFinite(video.duration) ? video.duration : 0;
setDuration(nextDuration);
onLoadedMetadata?.({
duration: nextDuration || null,
dimensions: video.videoWidth && video.videoHeight
? { width: video.videoWidth, height: video.videoHeight }
: null,
});
}}
onLoadedData={() => setHasLoadedFrame(true)}
onTimeUpdate={(event) => {
if (!isScrubbing) setCurrentTime(event.currentTarget.currentTime);
}}
onPlay={() => {
setHasLoadedFrame(true);
setIsPlaying(true);
}}
onPause={() => setIsPlaying(false)}
onError={onError}
/>
{poster && shouldShowPoster && (
<img
src={poster}
alt=""
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
/>
)}
{isRunning && (
<div className="pointer-events-none absolute inset-0 bg-black/20" />
)}
{!isRunning && !isPlaying && (
<button
type="button"
onClick={handleTogglePlayback}
className="nodrag nopan absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white shadow-lg transition-colors hover:bg-black/70"
title={playPauseLabel}
>
<svg className="ml-0.5 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</button>
)}
<div className="nodrag nopan nowheel absolute bottom-0 left-0 right-0 flex items-center gap-2 px-3 pb-2 pt-5 text-white">
<button
type="button"
onClick={handleTogglePlayback}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
title={playPauseLabel}
>
{isPlaying ? (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
) : (
<svg className="ml-0.5 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="w-9 shrink-0 text-[11px] tabular-nums text-white/85">{formatTime(currentTime)}</span>
<div
role="slider"
aria-valuemin={0}
aria-valuemax={Math.max(0, duration)}
aria-valuenow={Math.min(currentTime, duration)}
tabIndex={0}
className="relative h-5 min-w-0 flex-1 cursor-pointer touch-none"
onPointerDown={handleProgressPointerDown}
onPointerMove={handleProgressPointerMove}
onPointerUp={handleProgressPointerUp}
onPointerCancel={handleProgressPointerUp}
>
<div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/30" />
<div
className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white"
style={{ width: `${progress}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
style={{ left: `${progress}%` }}
/>
</div>
<span className="w-9 shrink-0 text-right text-[11px] tabular-nums text-white/85">{formatTime(duration)}</span>
<Popover
trigger="hover"
placement="top"
arrow={false}
color="rgba(0,0,0,0.28)"
content={volumePopoverContent}
styles={compactPopoverStyles}
>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
title={volumeLabel}
>
{isMuted || volume === 0 ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10v4h4l5 4V6l-5 4H4Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m17 9 4 4m0-4-4 4" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10v4h4l5 4V6l-5 4H4Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16 9.5a4 4 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10" />
</svg>
)}
</button>
</Popover>
{onCaptureFrame && (
<Popover
trigger="hover"
placement="topRight"
arrow={false}
color="rgba(0,0,0,0.28)"
content={capturePopoverContent}
styles={compactPopoverStyles}
>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={isCaptureLoading || isRunning || !hasLoadedFrame}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
title={captureTitle}
>
{isCaptureLoading ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 8.5A2.5 2.5 0 0 1 7.5 6H9l1.2-1.5h3.6L15 6h1.5A2.5 2.5 0 0 1 19 8.5v7A2.5 2.5 0 0 1 16.5 18h-9A2.5 2.5 0 0 1 5 15.5v-7Z" />
<circle cx="12" cy="12" r="2.5" />
</svg>
)}
</button>
</Popover>
)}
</div>
</div>
);
}