From 83aaa7a052bd41bb809c6c20a6adf0e8cbfdd9f2 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sat, 30 Aug 2025 11:34:36 +0800 Subject: [PATCH] feat: Add Artplayer as video player --- web/bun.lock | 7 + web/package.json | 1 + web/src/apps/VideoPlayer/VideoPlayer.tsx | 422 ++--------------------- 3 files changed, 38 insertions(+), 392 deletions(-) diff --git a/web/bun.lock b/web/bun.lock index a33d531..03123e8 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/package.json b/web/package.json index c93f3a3..4c9a1a6 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/apps/VideoPlayer/VideoPlayer.tsx b/web/src/apps/VideoPlayer/VideoPlayer.tsx index 73db84a..eb4abfa 100644 --- a/web/src/apps/VideoPlayer/VideoPlayer.tsx +++ b/web/src/apps/VideoPlayer/VideoPlayer.tsx @@ -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 = ({ filePath }) => { - const containerRef = useRef(null); - const videoRef = useRef(null); - const progressBarRef = useRef(null); - const progressRef = useRef(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(); - const [url, setUrl] = useState(); - const [showControls, setShowControls] = useState(true); - const [retryKey, setRetryKey] = useState(0); - const controlsTimerRef = useRef(undefined); - - useEffect(() => { - isMountedRef.current = true; - - return () => { - isMountedRef.current = false; - if (controlsTimerRef.current) { - window.clearTimeout(controlsTimerRef.current); - } - }; - }, []); + const artRef = useRef(null); + const artInstance = useRef(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) => { - 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) => { - 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 (
-
- {/* 视频元素 */} -
+ ref={artRef} + style={{ + width: '100%', + height: '100%', + backgroundColor: '#000' + }} + /> ); };