mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 03:52:41 +08:00
feat: Add Artplayer as video player
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user