feat: Add Artplayer as video player

This commit is contained in:
shiyu
2025-08-30 11:34:36 +08:00
parent a2638f077c
commit 83aaa7a052
3 changed files with 38 additions and 392 deletions

View File

@@ -8,6 +8,7 @@
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.0",
"artplayer": "^5.2.5",
"date-fns": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -316,6 +317,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"artplayer": ["artplayer@5.2.5", "", { "dependencies": { "option-validator": "^2.0.6" } }, "sha512-Ogym5rvkAJ4VLncM4Apl3TJ/a/ozM3csvY4IKuuMR++hUmEZgj/HaGsNonwx8r56nsqiZYE7O4vS1HFZl+NBSg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -532,6 +535,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -646,6 +651,8 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"option-validator": ["option-validator@2.0.6", "", { "dependencies": { "kind-of": "^6.0.3" } }, "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],

View File

@@ -14,6 +14,7 @@
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.0",
"artplayer": "^5.2.5",
"date-fns": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",

View File

@@ -1,408 +1,46 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import Artplayer from 'artplayer';
import { vfsApi } from '../../api/client';
import type { AppComponentProps } from '../types';
import { Spin, Button } from 'antd';
import {
PauseOutlined,
CaretRightOutlined,
SoundOutlined,
FullscreenOutlined,
ReloadOutlined
} from '@ant-design/icons';
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const progressBarRef = useRef<HTMLDivElement | null>(null);
const progressRef = useRef<HTMLDivElement | null>(null);
const isMountedRef = useRef(true);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(0.7);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
const [url, setUrl] = useState<string>();
const [showControls, setShowControls] = useState(true);
const [retryKey, setRetryKey] = useState(0);
const controlsTimerRef = useRef<number | undefined>(undefined);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (controlsTimerRef.current) {
window.clearTimeout(controlsTimerRef.current);
}
};
}, []);
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
useEffect(() => {
//
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
const u = vfsApi.streamUrl(safePath);
setUrl(u);
setErr(undefined);
setLoading(true);
}, [filePath, retryKey]);
const videoUrl = vfsApi.streamUrl(safePath);
// 处理视频事件
useEffect(() => {
const video = videoRef.current;
if (!video || !url) return;
const onLoadedMetadata = () => {
if (isMountedRef.current) {
setDuration(video.duration);
}
};
const onTimeUpdate = () => {
if (isMountedRef.current) {
setCurrentTime(video.currentTime);
updateProgressBar();
}
};
const onCanPlay = () => {
if (isMountedRef.current) {
setLoading(false);
}
};
const onEnded = () => {
if (isMountedRef.current) {
setIsPlaying(false);
}
};
const onError = () => {
if (isMountedRef.current) {
setLoading(false);
setErr('视频加载失败');
}
};
const onPlay = () => {
if (isMountedRef.current) {
setIsPlaying(true);
}
};
const onPause = () => {
if (isMountedRef.current) {
setIsPlaying(false);
}
};
const onProgress = () => {
// 监听缓冲进度
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
if (progressBarRef.current) {
const bufferProgress = bufferedEnd / video.duration * 100;
progressBarRef.current.style.setProperty('--buffer-width', `${bufferProgress}%`);
}
}
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('ended', onEnded);
video.addEventListener('error', onError);
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('progress', onProgress);
if (artRef.current) {
artInstance.current = new Artplayer({
container: artRef.current,
url: videoUrl,
autoplay: true,
fullscreen: true,
fullscreenWeb: true,
pip: true,
setting: true,
playbackRate: true,
});
}
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('ended', onEnded);
video.removeEventListener('error', onError);
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('progress', onProgress);
};
}, [url]);
// 处理进度条更新
const updateProgressBar = () => {
const video = videoRef.current;
const progress = progressRef.current;
if (video && progress && duration > 0) {
const percentage = (video.currentTime / duration) * 100;
progress.style.width = `${percentage}%`;
}
};
// 处理进度条点击
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
const progressBar = progressBarRef.current;
const video = videoRef.current;
if (progressBar && video) {
const rect = progressBar.getBoundingClientRect();
const clickPosition = e.clientX - rect.left;
const percentage = clickPosition / rect.width;
const newTime = percentage * duration;
video.currentTime = newTime;
setCurrentTime(newTime);
}
};
// 播放/暂停
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play().catch(error => {
console.error('播放失败:', error);
setErr('播放失败');
});
}
};
// 全屏
const toggleFullscreen = () => {
const container = containerRef.current;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error('全屏失败:', err);
});
} else {
document.exitFullscreen();
}
};
// 音量控制
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
const video = videoRef.current;
if (video) {
video.volume = newVolume;
setIsMuted(newVolume === 0);
}
};
// 静音切换
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
const newMuted = !isMuted;
setIsMuted(newMuted);
video.muted = newMuted;
};
// 格式化时间显示
const formatTime = (seconds: number): string => {
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 控制栏自动隐藏
const resetControlsTimer = () => {
if (controlsTimerRef.current) {
window.clearTimeout(controlsTimerRef.current);
}
setShowControls(true);
controlsTimerRef.current = window.setTimeout(() => {
if (isPlaying && isMountedRef.current) {
setShowControls(false);
if (artInstance.current) {
artInstance.current.destroy();
}
}, 3000);
};
const handleMouseMove = () => {
resetControlsTimer();
};
const retry = () => setRetryKey(k => k + 1);
};
}, [filePath]);
return (
<div
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}
ref={containerRef}
onMouseMove={handleMouseMove}
>
<div style={{ flex: 1, position: 'relative', backgroundColor: '#000', overflow: 'hidden' }}>
{/* 视频元素 */}
<video
ref={videoRef}
style={{
width: '100%',
height: '100%',
objectFit: 'contain'
}}
src={url}
controlsList="nodownload"
crossOrigin="anonymous"
preload="metadata"
onClick={togglePlay}
/>
{/* 加载指示器 */}
{loading && !err && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.35)', gap: 12 }}>
<Spin />
<span style={{ fontSize: 12, color: '#aaa' }}>...</span>
</div>
)}
{/* 错误显示 */}
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', gap: 12 }}>
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{err}</span>
<Button icon={<ReloadOutlined />} size="small" onClick={retry}></Button>
</div>
)}
{/* 控制栏 */}
{showControls && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
padding: '30px 15px 10px',
transition: 'opacity 0.3s',
opacity: showControls ? 1 : 0,
display: 'flex',
flexDirection: 'column',
gap: '8px'
}}
>
{/* 进度条 */}
<div
ref={progressBarRef}
onClick={handleProgressBarClick}
style={{
height: '4px',
backgroundColor: 'rgba(255,255,255,0.2)',
cursor: 'pointer',
position: 'relative',
borderRadius: '2px',
'--buffer-width': '0%'
} as React.CSSProperties}
>
<div
style={{
position: 'absolute',
height: '100%',
width: 'var(--buffer-width)',
backgroundColor: 'rgba(255,255,255,0.4)',
borderRadius: '2px'
}}
/>
<div
ref={progressRef}
style={{
height: '100%',
width: '0%',
backgroundColor: '#1890ff',
position: 'relative',
borderRadius: '2px',
}}
>
<div
style={{
position: 'absolute',
right: '-6px',
top: '-4px',
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: '#1890ff',
}}
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Button
type="text"
icon={isPlaying ? <PauseOutlined /> : <CaretRightOutlined />}
onClick={togglePlay}
style={{ color: '#fff' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100px' }}>
<Button
type="text"
icon={<SoundOutlined />}
onClick={toggleMute}
style={{ color: isMuted ? '#888' : '#fff' }}
/>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
style={{ width: '60px' }}
/>
</div>
<div style={{ color: '#fff', fontSize: '12px' }}>
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div>
<Button
type="text"
icon={<FullscreenOutlined />}
onClick={toggleFullscreen}
style={{ color: '#fff' }}
/>
</div>
</div>
</div>
)}
{!isPlaying && !loading && !err && (
<div
onClick={togglePlay}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
>
<CaretRightOutlined style={{ fontSize: '24px', color: '#fff' }} />
</div>
)}
</div>
</div>
ref={artRef}
style={{
width: '100%',
height: '100%',
backgroundColor: '#000'
}}
/>
);
};