fix: video player

This commit is contained in:
root 2025-07-11 18:07:43 +08:00
parent c75985188e
commit 18b3905c2b
2 changed files with 597 additions and 55 deletions

388
docs/video-player-usage.md Normal file
View File

@ -0,0 +1,388 @@
# VideoPlayer 组件使用指南
基于 demo.md 重写的 VideoPlayer 组件使用说明
## 🎯 设计特点
### 参考 demo.md 的优秀设计模式
1. **useImperativeHandle**: 暴露组件方法供外部调用
2. **清晰的接口定义**: TypeScript 接口规范
3. **完善的事件处理**: 加载、播放、错误等事件回调
4. **加载状态管理**: 统一的加载状态显示
5. **资源清理机制**: 防止内存泄漏
6. **错误处理回调**: 可自定义错误处理逻辑
## 📚 接口定义
### VideoPlayerRef
```typescript
export interface VideoPlayerRef {
seek(time: number): void; // 跳转到指定时间
pause(): void; // 暂停播放
play(): void; // 开始播放
getCurrentTime(): number; // 获取当前播放时间
isPaused(): boolean; // 是否暂停状态
getDuration(): number; // 获取视频总时长
captureFrame(): string | undefined; // 截取当前帧
fullScreen(): void; // 全屏播放
reload(): void; // 重新加载视频
}
```
### 事件回调接口
```typescript
export interface OnSourceLoaded {
(player: HTMLVideoElement | null): void;
}
export interface OnPlayerError {
(err: Error, player: HTMLVideoElement | null): void;
}
export interface OnPlayerTimeChange {
(player: HTMLVideoElement | null): void;
}
```
### 组件Props
```typescript
interface VideoPlayerProps {
videoPath: string // 视频文件路径
isOpen: boolean // 是否打开播放器
onClose: () => void // 关闭回调
title?: string // 播放器标题
onSourceLoaded?: OnSourceLoaded // 视频加载完成回调
onError?: OnPlayerError // 错误处理回调
onTimeChange?: OnPlayerTimeChange // 时间变化回调
}
```
## 🔧 基本使用
### 1. 简单使用
```tsx
import VideoPlayer from '@/components/VideoPlayer'
function MyComponent() {
const [isPlayerOpen, setIsPlayerOpen] = useState(false)
const [videoPath, setVideoPath] = useState('')
return (
<>
<button onClick={() => setIsPlayerOpen(true)}>
播放视频
</button>
<VideoPlayer
videoPath={videoPath}
isOpen={isPlayerOpen}
onClose={() => setIsPlayerOpen(false)}
title="我的视频"
/>
</>
)
}
```
### 2. 带事件处理的使用
```tsx
import VideoPlayer, { VideoPlayerRef } from '@/components/VideoPlayer'
function AdvancedVideoPlayer() {
const playerRef = useRef<VideoPlayerRef>(null)
const [isPlayerOpen, setIsPlayerOpen] = useState(false)
const [videoPath, setVideoPath] = useState('')
const handleSourceLoaded = (player: HTMLVideoElement | null) => {
console.log('视频加载完成:', {
duration: player?.duration,
videoWidth: player?.videoWidth,
videoHeight: player?.videoHeight
})
}
const handleError = (err: Error, player: HTMLVideoElement | null) => {
console.error('视频播放错误:', err.message)
// 可以显示自定义错误提示
alert(`播放失败: ${err.message}`)
}
const handleTimeChange = (player: HTMLVideoElement | null) => {
if (player) {
console.log(`播放进度: ${player.currentTime}/${player.duration}`)
}
}
const handleCaptureFrame = () => {
const frameData = playerRef.current?.captureFrame()
if (frameData) {
console.log('截取帧数据:', frameData)
// 可以保存或显示截图
}
}
return (
<>
<div className="space-x-2">
<button onClick={() => setIsPlayerOpen(true)}>
播放视频
</button>
<button onClick={() => playerRef.current?.play()}>
播放
</button>
<button onClick={() => playerRef.current?.pause()}>
暂停
</button>
<button onClick={() => playerRef.current?.seek(30)}>
跳转到30秒
</button>
<button onClick={handleCaptureFrame}>
截取当前帧
</button>
<button onClick={() => playerRef.current?.fullScreen()}>
全屏
</button>
</div>
<VideoPlayer
ref={playerRef}
videoPath={videoPath}
isOpen={isPlayerOpen}
onClose={() => setIsPlayerOpen(false)}
title="高级视频播放器"
onSourceLoaded={handleSourceLoaded}
onError={handleError}
onTimeChange={handleTimeChange}
/>
</>
)
}
```
## 🎮 控制方法
### 播放控制
```typescript
// 播放视频
playerRef.current?.play()
// 暂停视频
playerRef.current?.pause()
// 检查播放状态
const isPaused = playerRef.current?.isPaused()
```
### 时间控制
```typescript
// 跳转到指定时间(秒)
playerRef.current?.seek(120) // 跳转到2分钟
// 获取当前播放时间
const currentTime = playerRef.current?.getCurrentTime()
// 获取视频总时长
const duration = playerRef.current?.getDuration()
```
### 高级功能
```typescript
// 截取当前帧
const frameDataUrl = playerRef.current?.captureFrame()
if (frameDataUrl) {
// 可以用于显示缩略图或保存截图
const img = new Image()
img.src = frameDataUrl
document.body.appendChild(img)
}
// 全屏播放
playerRef.current?.fullScreen()
// 重新加载视频
playerRef.current?.reload()
```
## 🔄 事件处理
### 视频加载完成
```typescript
const handleSourceLoaded = (player: HTMLVideoElement | null) => {
if (player) {
console.log('视频信息:', {
duration: player.duration,
width: player.videoWidth,
height: player.videoHeight,
readyState: player.readyState
})
// 可以在这里设置默认音量、播放速度等
player.volume = 0.8
player.playbackRate = 1.0
}
}
```
### 错误处理
```typescript
const handleError = (err: Error, player: HTMLVideoElement | null) => {
console.error('播放错误:', err)
// 根据错误类型进行不同处理
if (err.message.includes('文件不存在')) {
// 处理文件不存在的情况
showNotification('视频文件不存在,请检查文件路径')
} else if (err.message.includes('格式不支持')) {
// 处理格式不支持的情况
showNotification('视频格式不支持请转换为MP4格式')
} else {
// 通用错误处理
showNotification('视频播放失败,请重试')
}
}
```
### 播放进度监听
```typescript
const handleTimeChange = (player: HTMLVideoElement | null) => {
if (player) {
const progress = (player.currentTime / player.duration) * 100
// 更新进度条
setPlayProgress(progress)
// 记录观看进度
saveWatchProgress(videoId, player.currentTime)
// 在特定时间点触发事件
if (Math.floor(player.currentTime) === 60) {
console.log('播放到1分钟了')
}
}
}
```
## 🎨 样式定制
### 加载状态自定义
组件内置了加载状态显示,使用 Loader2 图标和半透明背景。如需自定义,可以修改组件内的样式:
```tsx
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="flex flex-col items-center space-y-2">
<Loader2 className="h-8 w-8 animate-spin text-white" />
<span className="text-white text-sm">加载中...</span>
</div>
</div>
)}
```
### 视频容器样式
```tsx
<video
className="w-full h-auto max-h-[70vh] bg-black"
crossOrigin="anonymous"
preload="auto"
/>
```
## 🔧 最佳实践
### 1. 错误处理
```typescript
// 总是提供错误处理回调
<VideoPlayer
onError={(err, player) => {
// 记录错误日志
console.error('Video error:', err)
// 显示用户友好的错误信息
toast.error('视频播放失败,请重试')
// 可选:自动重试
setTimeout(() => {
playerRef.current?.reload()
}, 2000)
}}
/>
```
### 2. 性能优化
```typescript
// 使用 useMemo 缓存回调函数
const handleSourceLoaded = useMemo(() => (player: HTMLVideoElement | null) => {
// 处理逻辑
}, [])
const handleError = useMemo(() => (err: Error, player: HTMLVideoElement | null) => {
// 错误处理逻辑
}, [])
```
### 3. 资源管理
```typescript
// 组件卸载时清理资源
useEffect(() => {
return () => {
// VideoPlayer 内部已经处理了资源清理
// 但如果有外部资源需要清理,可以在这里处理
}
}, [])
```
## 🚀 高级用法
### 视频分析和处理
```typescript
const VideoAnalyzer = () => {
const playerRef = useRef<VideoPlayerRef>(null)
const [frames, setFrames] = useState<string[]>([])
const captureFramesAtInterval = () => {
const duration = playerRef.current?.getDuration()
if (!duration) return
const interval = duration / 10 // 每10%截取一帧
const framePromises = []
for (let i = 0; i < 10; i++) {
const time = i * interval
framePromises.push(
new Promise<string>((resolve) => {
playerRef.current?.seek(time)
setTimeout(() => {
const frame = playerRef.current?.captureFrame()
resolve(frame || '')
}, 100)
})
)
}
Promise.all(framePromises).then(setFrames)
}
return (
<div>
<button onClick={captureFramesAtInterval}>
生成视频缩略图
</button>
<div className="grid grid-cols-5 gap-2 mt-4">
{frames.map((frame, index) => (
<img key={index} src={frame} alt={`Frame ${index}`} />
))}
</div>
<VideoPlayer
ref={playerRef}
// ... 其他props
/>
</div>
)
}
```
---
*重写后的 VideoPlayer 组件提供了更强大的功能和更好的开发体验!*

View File

@ -1,19 +1,51 @@
import React, { useState, useRef, useEffect } from 'react'
import { Play, Pause, Volume2, VolumeX, Maximize2, X, AlertCircle } from 'lucide-react'
import React, { useState, useRef, useEffect, useImperativeHandle, forwardRef } from 'react'
import { Play, Pause, Volume2, VolumeX, Maximize2, X, AlertCircle, Loader2 } from 'lucide-react'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
// 接口定义
export interface OnSourceLoaded {
(player: HTMLVideoElement | null): void;
}
export interface OnPlayerError {
(err: Error, player: HTMLVideoElement | null): void;
}
export interface OnPlayerTimeChange {
(player: HTMLVideoElement | null): void;
}
export interface VideoPlayerRef {
seek(time: number): void;
pause(): void;
play(): void;
getCurrentTime(): number;
isPaused(): boolean;
getDuration(): number;
captureFrame(): string | undefined;
fullScreen(): void;
reload(): void;
}
interface VideoPlayerProps {
videoPath: string
isOpen: boolean
onClose: () => void
title?: string
onSourceLoaded?: OnSourceLoaded
onError?: OnPlayerError
onTimeChange?: OnPlayerTimeChange
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
videoPath,
isOpen,
onClose,
title = '视频播放'
}) => {
title = '视频播放',
onSourceLoaded,
onError,
onTimeChange
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [isMuted, setIsMuted] = useState(false)
@ -25,7 +57,98 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [fileExists, setFileExists] = useState<boolean>(true)
const [errorMessage, setErrorMessage] = useState<string>('')
const [loadingMethod, setLoadingMethod] = useState<'convertFileSrc' | 'dataUrl'>('convertFileSrc')
const [isLoading, setIsLoading] = useState(true)
// 暴露组件方法
useImperativeHandle(ref, () => ({
seek: (time: number) => {
const video = videoRef.current
if (video) {
video.currentTime = time
setCurrentTime(time)
}
},
pause: () => {
videoRef.current?.pause()
},
play: () => {
videoRef.current?.play().catch(err => {
console.error('Play failed:', err)
onError?.(new Error('播放失败'), videoRef.current)
})
},
isPaused: () => {
return !!videoRef.current?.paused
},
getCurrentTime: () => {
return videoRef.current?.currentTime || 0
},
getDuration: () => {
return videoRef.current?.duration || 0
},
captureFrame: () => {
const video = videoRef.current
if (!video) return undefined
// 创建canvas元素
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return undefined
// 设置canvas尺寸
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// 绘制视频帧到canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataURL = canvas.toDataURL('image/jpeg', 0.8)
return dataURL
},
fullScreen: () => {
const video = videoRef.current
if (video) {
video.requestFullscreen().catch(err => {
console.error('Fullscreen failed:', err)
})
}
},
reload: () => {
if (videoPath) {
tryLoadVideo(videoPath)
}
}
}))
// 加载状态管理
useEffect(() => {
const video = videoRef.current
if (!video) return
const showLoading = () => setIsLoading(true)
const hideLoading = () => setIsLoading(false)
// 加载状态事件
video.addEventListener('waiting', showLoading)
video.addEventListener('seeking', showLoading)
video.addEventListener('loadstart', showLoading)
video.addEventListener('playing', hideLoading)
video.addEventListener('seeked', hideLoading)
video.addEventListener('canplay', hideLoading)
video.addEventListener('loadeddata', hideLoading)
return () => {
video.removeEventListener('waiting', showLoading)
video.removeEventListener('seeking', showLoading)
video.removeEventListener('loadstart', showLoading)
video.removeEventListener('playing', hideLoading)
video.removeEventListener('seeked', hideLoading)
video.removeEventListener('canplay', hideLoading)
video.removeEventListener('loadeddata', hideLoading)
}
}, [])
// 视频源加载和事件处理
useEffect(() => {
const checkFileAndSetSrc = async () => {
if (isOpen && videoPath) {
@ -37,6 +160,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (!exists) {
setFileExists(false)
setErrorMessage(`文件不存在: ${videoPath}`)
onError?.(new Error(`文件不存在: ${videoPath}`), videoRef.current)
return
}
@ -48,13 +172,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
} catch (error) {
console.error('Error checking file:', error)
setFileExists(false)
setErrorMessage(`文件检查失败: ${error}`)
const errorMsg = `文件检查失败: ${error}`
setErrorMessage(errorMsg)
onError?.(new Error(errorMsg), videoRef.current)
}
}
}
checkFileAndSetSrc()
}, [isOpen, videoPath])
}, [isOpen, videoPath, onError])
// 尝试多种方法加载视频
const tryLoadVideo = async (path: string) => {
@ -83,21 +209,56 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
}
}
// 视频播放事件处理
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleTimeUpdate = () => setCurrentTime(video.currentTime)
const handleDurationChange = () => setDuration(video.duration)
const handleEnded = () => setIsPlaying(false)
const handlePlay = () => setIsPlaying(true)
const handlePause = () => setIsPlaying(false)
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime)
onTimeChange?.(video)
}
const handleDurationChange = () => {
setDuration(video.duration)
}
const handleEnded = () => {
setIsPlaying(false)
}
const handlePlay = () => {
setIsPlaying(true)
}
const handlePause = () => {
setIsPlaying(false)
}
const handleLoadedMetadata = () => {
console.log('Video metadata loaded:', {
duration: video.duration,
videoWidth: video.videoWidth,
videoHeight: video.videoHeight
})
onSourceLoaded?.(video)
}
const handleError = (e: Event) => {
console.error('Video error:', e)
const error = new Error('视频播放错误')
setErrorMessage('视频播放错误')
onError?.(error, video)
}
// 添加事件监听器
video.addEventListener('timeupdate', handleTimeUpdate)
video.addEventListener('durationchange', handleDurationChange)
video.addEventListener('ended', handleEnded)
video.addEventListener('play', handlePlay)
video.addEventListener('pause', handlePause)
video.addEventListener('loadedmetadata', handleLoadedMetadata)
video.addEventListener('error', handleError)
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate)
@ -105,24 +266,29 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
video.removeEventListener('ended', handleEnded)
video.removeEventListener('play', handlePlay)
video.removeEventListener('pause', handlePause)
video.removeEventListener('loadedmetadata', handleLoadedMetadata)
video.removeEventListener('error', handleError)
}
}, [])
}, [onTimeChange, onSourceLoaded, onError])
// 单独处理视频源设置
// 视频源设置和清理
useEffect(() => {
const video = videoRef.current
if (!video || !videoSrc) return
// 清理旧的source元素
while (video.firstChild) {
video.removeChild(video.firstChild)
}
// 直接设置src而不是添加source元素
// 设置视频源
video.src = videoSrc
video.load() // 重新加载视频
video.load()
console.log('Video src set to:', videoSrc)
// 清理函数
return () => {
if (video) {
video.src = ''
video.load()
}
}
}, [videoSrc])
const handlePlayPause = () => {
@ -240,39 +406,25 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
</div>
</div>
) : (
<video
ref={videoRef}
className="w-full h-auto max-h-[70vh]"
onClick={handlePlayPause}
onError={async (e) => {
console.error('Video loading error:', {
error: e,
videoSrc,
originalPath: videoPath,
currentTarget: e.currentTarget,
networkState: e.currentTarget.networkState,
readyState: e.currentTarget.readyState,
currentMethod: loadingMethod
})
// 如果当前视频源失败,尝试重新加载
if (videoPath) {
console.log('Video error detected, attempting reload...')
tryLoadVideo(videoPath).catch(err => {
console.error('Reload failed:', err)
setErrorMessage('视频加载失败:文件可能损坏或格式不支持')
})
}
}}
onLoadStart={() => {
console.log('Video load started:', videoSrc)
}}
onCanPlay={() => {
console.log('Video can play:', videoSrc)
}}
onLoadedData={() => {
console.log('Video data loaded:', videoSrc)
}}
/>
<div className="relative w-full">
<video
ref={videoRef}
className="w-full h-auto max-h-[70vh] bg-black"
onClick={handlePlayPause}
crossOrigin="anonymous"
preload="auto"
/>
{/* 加载状态显示 */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="flex flex-col items-center space-y-2">
<Loader2 className="h-8 w-8 animate-spin text-white" />
<span className="text-white text-sm">...</span>
</div>
</div>
)}
</div>
)}
{/* 控制栏 */}
@ -340,6 +492,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
</div>
</div>
)
}
})
VideoPlayer.displayName = 'VideoPlayer'
export default VideoPlayer