fix: video player
This commit is contained in:
205
demo.md
Normal file
205
demo.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { container, CoreEnv } from '@roasmax/core';
|
||||||
|
import { snowflake } from '@roasmax/utils';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { LocAttachmentController } from '@roasmax/sdk';
|
||||||
|
|
||||||
|
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 LiveStreamPlayerRef {
|
||||||
|
seek(time: number): void;
|
||||||
|
pause(): void;
|
||||||
|
play(): void;
|
||||||
|
getCurrentTime(): number;
|
||||||
|
isPaused(): boolean;
|
||||||
|
getDuration(): number;
|
||||||
|
captureFrame(): string | undefined;
|
||||||
|
fullScreen(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCaptureFrame(content: string, projectId: string): Promise<{ urn: string }> {
|
||||||
|
const coreEnv = container.resolve(CoreEnv);
|
||||||
|
return axios
|
||||||
|
.create({
|
||||||
|
baseURL: coreEnv.MODAL_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${coreEnv.MODAL_SERVER_KEY || 'bowong7777'}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.request({
|
||||||
|
url: `/cache/upload-s3-b64`,
|
||||||
|
method: `post`,
|
||||||
|
data: {
|
||||||
|
file: {
|
||||||
|
raw_content: content,
|
||||||
|
filename: `images/${projectId}/${snowflake.nextId()}.png`,
|
||||||
|
content_type: `application/json`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LiveStreamPlayer: React.FC<{
|
||||||
|
id?: string;
|
||||||
|
ref?: React.RefObject<LiveStreamPlayerRef | null>;
|
||||||
|
onSourceLoaded: OnSourceLoaded;
|
||||||
|
onError: OnPlayerError;
|
||||||
|
onTimeChange: OnPlayerTimeChange;
|
||||||
|
}> = ({ id, onSourceLoaded, onError, onTimeChange, ref }) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const { data: attachment } = useQuery({
|
||||||
|
queryKey: [`generateUrlById`, id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!id) return ``;
|
||||||
|
const c = container.resolve(LocAttachmentController);
|
||||||
|
const attachment = await c.get(id);
|
||||||
|
return attachment;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
seek: (time: number) => {
|
||||||
|
videoRef.current && (videoRef.current.currentTime = time);
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
},
|
||||||
|
play: () => {
|
||||||
|
videoRef.current?.play();
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
// 创建canvas元素
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
// 设置canvas尺寸
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
// 上传到modal
|
||||||
|
// 绘制视频帧到canvas
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const dataURL = canvas.toDataURL('image/jpeg', 0.05);
|
||||||
|
return dataURL;
|
||||||
|
},
|
||||||
|
fullScreen: () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
video.requestFullscreen();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('playing', hideLoading);
|
||||||
|
video.addEventListener('seeked', hideLoading);
|
||||||
|
video.addEventListener('canplay', hideLoading);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!video) return;
|
||||||
|
video.removeEventListener('waiting', showLoading);
|
||||||
|
video.removeEventListener('seeking', showLoading);
|
||||||
|
video.removeEventListener('playing', hideLoading);
|
||||||
|
video.removeEventListener('seeked', hideLoading);
|
||||||
|
video.removeEventListener('canplay', hideLoading);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !attachment) return;
|
||||||
|
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', (a) => {
|
||||||
|
return onSourceLoaded(video);
|
||||||
|
});
|
||||||
|
video.addEventListener('timeupdate', () => onTimeChange(video));
|
||||||
|
video.addEventListener('error', () => onError(new Error('Video error'), video));
|
||||||
|
if (attachment.type === 'live-stream') {
|
||||||
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari等原生支持HLS
|
||||||
|
video.src = attachment.url!;
|
||||||
|
video.poster = attachment.coverUrl!;
|
||||||
|
} else if (Hls.isSupported()) {
|
||||||
|
// 其他浏览器用hls.js
|
||||||
|
video.poster = attachment.coverUrl!;
|
||||||
|
const hls = new Hls();
|
||||||
|
hlsRef.current = hls;
|
||||||
|
// attachment.url!
|
||||||
|
console.log(attachment.url);
|
||||||
|
hls.loadSource(attachment.url!);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR) {
|
||||||
|
const corsError = new Error('CORS error: Check server Access-Control-Allow-Origin headers');
|
||||||
|
onError(corsError, video);
|
||||||
|
} else {
|
||||||
|
onError(new Error(data?.details || 'HLS.js error'), video);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onError(new Error('HLS is not supported in this browser'), video);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
video.src = attachment.url!;
|
||||||
|
video.poster = attachment.coverUrl!;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
if (video) {
|
||||||
|
video.src = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [attachment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<video ref={videoRef} crossOrigin="anonymous" className="h-full w-full bg-[#18181A]" autoPlay preload="auto" />
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-12 w-12 animate-spin rounded-full border-4 border-solid border-white border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
187
scripts/test_video_fix.md
Normal file
187
scripts/test_video_fix.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# 视频播放问题修复验证
|
||||||
|
|
||||||
|
## 🔍 问题分析总结
|
||||||
|
|
||||||
|
### 发现的关键问题
|
||||||
|
|
||||||
|
#### 1. **CSP配置缺失** (主要原因)
|
||||||
|
**问题**: CSP中缺少`media-src`和`video-src`指令
|
||||||
|
```json
|
||||||
|
// 修复前
|
||||||
|
"csp": "default-src 'self'; img-src 'self' asset: data: http://asset.localhost; ..."
|
||||||
|
|
||||||
|
// 修复后
|
||||||
|
"csp": "default-src 'self'; img-src 'self' asset: data: http://asset.localhost; media-src 'self' asset: http://asset.localhost; video-src 'self' asset: http://asset.localhost; ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **VideoPlayer组件问题**
|
||||||
|
- **动态添加source元素**: 每次都添加新的source,导致重复和冲突
|
||||||
|
- **错误处理不完善**: 缺少备用加载方案
|
||||||
|
- **convertFileSrc参数**: 之前有错误的第二个参数
|
||||||
|
|
||||||
|
### 为什么图片可以加载但视频不行?
|
||||||
|
|
||||||
|
| 资源类型 | CSP指令 | 修复前状态 | 修复后状态 |
|
||||||
|
|----------|---------|------------|------------|
|
||||||
|
| 图片 | `img-src` | ✅ 已配置 | ✅ 正常 |
|
||||||
|
| 视频 | `media-src` | ❌ 缺失 | ✅ 已添加 |
|
||||||
|
| 视频 | `video-src` | ❌ 缺失 | ✅ 已添加 |
|
||||||
|
|
||||||
|
## 🔧 修复内容
|
||||||
|
|
||||||
|
### 1. CSP配置修复
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; img-src 'self' asset: data: http://asset.localhost; media-src 'self' asset: http://asset.localhost; video-src 'self' asset: http://asset.localhost; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. VideoPlayer组件优化
|
||||||
|
|
||||||
|
#### A. 改进的视频加载逻辑
|
||||||
|
```typescript
|
||||||
|
const tryLoadVideo = async (path: string) => {
|
||||||
|
try {
|
||||||
|
// 首先尝试convertFileSrc方法
|
||||||
|
const src = convertFileSrc(path)
|
||||||
|
setVideoSrc(src)
|
||||||
|
setLoadingMethod('convertFileSrc')
|
||||||
|
} catch (error) {
|
||||||
|
// 备用方案:data URL方法
|
||||||
|
const dataUrl = await invoke<string>('read_video_as_data_url', { filePath: path })
|
||||||
|
setVideoSrc(dataUrl)
|
||||||
|
setLoadingMethod('dataUrl')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 修复video元素源设置
|
||||||
|
```typescript
|
||||||
|
// 修复前:动态添加source元素
|
||||||
|
const source = document.createElement('source');
|
||||||
|
source.src = videoSrc;
|
||||||
|
video.appendChild(source);
|
||||||
|
|
||||||
|
// 修复后:直接设置src
|
||||||
|
video.src = videoSrc
|
||||||
|
video.load()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 测试验证
|
||||||
|
|
||||||
|
### 1. 重启应用
|
||||||
|
```bash
|
||||||
|
# 重新启动开发服务器以应用CSP配置
|
||||||
|
pnpm tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 测试步骤
|
||||||
|
1. **导入视频文件**
|
||||||
|
- 尝试导入不同格式的视频 (MP4, AVI, MOV)
|
||||||
|
- 检查导入是否成功
|
||||||
|
|
||||||
|
2. **视频播放测试**
|
||||||
|
- 点击视频素材的播放按钮
|
||||||
|
- 观察是否能正常打开VideoPlayer
|
||||||
|
- 检查视频是否能正常播放
|
||||||
|
|
||||||
|
3. **控制台检查**
|
||||||
|
- 打开浏览器开发者工具
|
||||||
|
- 查看Console是否有CSP违规错误
|
||||||
|
- 检查Network请求是否成功
|
||||||
|
|
||||||
|
### 3. 预期结果
|
||||||
|
|
||||||
|
#### 成功标志
|
||||||
|
- ✅ 视频播放器能正常打开
|
||||||
|
- ✅ 视频内容能正常显示和播放
|
||||||
|
- ✅ 控制台没有CSP违规错误
|
||||||
|
- ✅ 播放控制按钮正常工作
|
||||||
|
|
||||||
|
#### 可能的日志输出
|
||||||
|
```
|
||||||
|
try load video src http://asset.localhost/C%3A%5CUsers%5C...%5Cvideo.mp4
|
||||||
|
Video load started: http://asset.localhost/...
|
||||||
|
Video can play: http://asset.localhost/...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 故障排除
|
||||||
|
|
||||||
|
### 如果视频仍然无法播放
|
||||||
|
|
||||||
|
#### 1. 检查CSP配置
|
||||||
|
在浏览器控制台运行:
|
||||||
|
```javascript
|
||||||
|
console.log(document.querySelector('meta[http-equiv="Content-Security-Policy"]').content)
|
||||||
|
```
|
||||||
|
确认包含:`media-src 'self' asset: http://asset.localhost; video-src 'self' asset: http://asset.localhost`
|
||||||
|
|
||||||
|
#### 2. 检查文件路径
|
||||||
|
```javascript
|
||||||
|
// 在控制台检查convertFileSrc结果
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
|
const testPath = 'C:\\Users\\...\\video.mp4'
|
||||||
|
console.log('Converted:', convertFileSrc(testPath))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 检查文件格式
|
||||||
|
确认视频文件格式被浏览器支持:
|
||||||
|
- ✅ MP4 (H.264/AAC)
|
||||||
|
- ✅ WebM (VP8/VP9)
|
||||||
|
- ❌ AVI (可能不支持)
|
||||||
|
- ❌ MOV (可能不支持)
|
||||||
|
|
||||||
|
#### 4. 检查文件大小
|
||||||
|
大文件可能导致加载超时:
|
||||||
|
- 建议测试小于50MB的视频文件
|
||||||
|
- 大文件可能需要更长的加载时间
|
||||||
|
|
||||||
|
### 常见错误和解决方案
|
||||||
|
|
||||||
|
#### 错误1: CSP违规
|
||||||
|
```
|
||||||
|
Refused to load media from 'asset://...' because it violates CSP
|
||||||
|
```
|
||||||
|
**解决**: 确认CSP配置正确,重启应用
|
||||||
|
|
||||||
|
#### 错误2: 文件不存在
|
||||||
|
```
|
||||||
|
Asset protocol access denied
|
||||||
|
```
|
||||||
|
**解决**: 检查文件路径和assetProtocol scope配置
|
||||||
|
|
||||||
|
#### 错误3: 格式不支持
|
||||||
|
```
|
||||||
|
The element has no supported sources
|
||||||
|
```
|
||||||
|
**解决**: 转换视频格式为MP4 (H.264)
|
||||||
|
|
||||||
|
## 📊 性能优化建议
|
||||||
|
|
||||||
|
### 1. 视频格式优化
|
||||||
|
- 推荐使用MP4格式 (H.264编码)
|
||||||
|
- 控制视频分辨率和码率
|
||||||
|
- 考虑视频压缩
|
||||||
|
|
||||||
|
### 2. 加载策略优化
|
||||||
|
- 对于大文件,优先使用convertFileSrc
|
||||||
|
- 对于小文件或特殊路径,使用data URL
|
||||||
|
- 实现加载进度显示
|
||||||
|
|
||||||
|
### 3. 缓存策略
|
||||||
|
- 考虑实现视频缓存机制
|
||||||
|
- 避免重复加载相同视频
|
||||||
|
|
||||||
|
## 🎯 下一步优化
|
||||||
|
|
||||||
|
1. **添加视频格式检测**
|
||||||
|
2. **实现加载进度条**
|
||||||
|
3. **优化大文件处理**
|
||||||
|
4. **添加视频预览缩略图**
|
||||||
|
5. **实现视频转码功能**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*修复完成后,视频播放功能应该能正常工作!*
|
||||||
@@ -58,22 +58,35 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
|
|
||||||
// 尝试多种方法加载视频
|
// 尝试多种方法加载视频
|
||||||
const tryLoadVideo = async (path: string) => {
|
const tryLoadVideo = async (path: string) => {
|
||||||
// 根据路径类型选择不同的加载策略
|
try {
|
||||||
const src = convertFileSrc(path)
|
// 首先尝试convertFileSrc方法
|
||||||
console.log(`try load video src`, src)
|
const src = convertFileSrc(path)
|
||||||
setVideoSrc(src)
|
console.log(`try load video src`, src)
|
||||||
setLoadingMethod('convertFileSrc')
|
setVideoSrc(src)
|
||||||
return
|
setLoadingMethod('convertFileSrc')
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
console.error('convertFileSrc failed:', error)
|
||||||
|
|
||||||
|
// 如果失败,尝试data URL方法
|
||||||
|
try {
|
||||||
|
console.log('Trying data URL method for:', path)
|
||||||
|
const dataUrl = await invoke<string>('read_video_as_data_url', { filePath: path })
|
||||||
|
console.log('Data URL method successful, length:', dataUrl.length)
|
||||||
|
setVideoSrc(dataUrl)
|
||||||
|
setLoadingMethod('dataUrl')
|
||||||
|
return
|
||||||
|
} catch (dataUrlError) {
|
||||||
|
console.error('Data URL method failed:', dataUrlError)
|
||||||
|
setErrorMessage('无法加载视频:所有加载方法都失败了')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
if(!videoSrc) return;
|
|
||||||
const source = document.createElement('source');
|
|
||||||
source.type = 'video/mp4';
|
|
||||||
source.src = videoSrc;
|
|
||||||
video.appendChild(source);
|
|
||||||
const handleTimeUpdate = () => setCurrentTime(video.currentTime)
|
const handleTimeUpdate = () => setCurrentTime(video.currentTime)
|
||||||
const handleDurationChange = () => setDuration(video.duration)
|
const handleDurationChange = () => setDuration(video.duration)
|
||||||
const handleEnded = () => setIsPlaying(false)
|
const handleEnded = () => setIsPlaying(false)
|
||||||
@@ -93,6 +106,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
video.removeEventListener('play', handlePlay)
|
video.removeEventListener('play', handlePlay)
|
||||||
video.removeEventListener('pause', handlePause)
|
video.removeEventListener('pause', handlePause)
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 单独处理视频源的设置
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || !videoSrc) return
|
||||||
|
|
||||||
|
// 清理旧的source元素
|
||||||
|
while (video.firstChild) {
|
||||||
|
video.removeChild(video.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接设置src而不是添加source元素
|
||||||
|
video.src = videoSrc
|
||||||
|
video.load() // 重新加载视频
|
||||||
|
|
||||||
|
console.log('Video src set to:', videoSrc)
|
||||||
}, [videoSrc])
|
}, [videoSrc])
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user