diff --git a/docs/video-player-usage.md b/docs/video-player-usage.md new file mode 100644 index 0000000..7139f78 --- /dev/null +++ b/docs/video-player-usage.md @@ -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 ( + <> + setIsPlayerOpen(true)}> + 播放视频 + + + setIsPlayerOpen(false)} + title="我的视频" + /> + > + ) +} +``` + +### 2. 带事件处理的使用 +```tsx +import VideoPlayer, { VideoPlayerRef } from '@/components/VideoPlayer' + +function AdvancedVideoPlayer() { + const playerRef = useRef(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 ( + <> + + setIsPlayerOpen(true)}> + 播放视频 + + playerRef.current?.play()}> + 播放 + + playerRef.current?.pause()}> + 暂停 + + playerRef.current?.seek(30)}> + 跳转到30秒 + + + 截取当前帧 + + playerRef.current?.fullScreen()}> + 全屏 + + + + 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 && ( + + + + 加载中... + + +)} +``` + +### 视频容器样式 +```tsx + +``` + +## 🔧 最佳实践 + +### 1. 错误处理 +```typescript +// 总是提供错误处理回调 + { + // 记录错误日志 + 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(null) + const [frames, setFrames] = useState([]) + + 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((resolve) => { + playerRef.current?.seek(time) + setTimeout(() => { + const frame = playerRef.current?.captureFrame() + resolve(frame || '') + }, 100) + }) + ) + } + + Promise.all(framePromises).then(setFrames) + } + + return ( + + + 生成视频缩略图 + + + + {frames.map((frame, index) => ( + + ))} + + + + + ) +} +``` + +--- + +*重写后的 VideoPlayer 组件提供了更强大的功能和更好的开发体验!* diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index e567a3d..143ffef 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -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 = ({ +const VideoPlayer = forwardRef(({ videoPath, isOpen, onClose, - title = '视频播放' -}) => { + title = '视频播放', + onSourceLoaded, + onError, + onTimeChange +}, ref) => { const videoRef = useRef(null) const [isPlaying, setIsPlaying] = useState(false) const [isMuted, setIsMuted] = useState(false) @@ -25,7 +57,98 @@ const VideoPlayer: React.FC = ({ const [fileExists, setFileExists] = useState(true) const [errorMessage, setErrorMessage] = useState('') 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 = ({ if (!exists) { setFileExists(false) setErrorMessage(`文件不存在: ${videoPath}`) + onError?.(new Error(`文件不存在: ${videoPath}`), videoRef.current) return } @@ -48,13 +172,15 @@ const VideoPlayer: React.FC = ({ } 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 = ({ } } + // 视频播放事件处理 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 = ({ 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 = ({ ) : ( - { - 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) - }} - /> + + + + {/* 加载状态显示 */} + {isLoading && ( + + + + 加载中... + + + )} + )} {/* 控制栏 */} @@ -340,6 +492,8 @@ const VideoPlayer: React.FC = ({ ) -} +}) + +VideoPlayer.displayName = 'VideoPlayer' export default VideoPlayer