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
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>
|
|
);
|
|
}
|
|
|