优化超分,优化弹幕弹窗
This commit is contained in:
@@ -1,254 +0,0 @@
|
||||
# LunaTV 弹幕功能集成说明
|
||||
|
||||
## 概述
|
||||
|
||||
LunaTV 已成功集成弹幕功能,支持自动搜索、手动选择和实时显示弹幕。弹幕数据来自 danmu_api 项目。
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 1. 启动 danmu_api 服务
|
||||
|
||||
在使用弹幕功能之前,需要先启动 danmu_api 服务:
|
||||
|
||||
```bash
|
||||
cd D:\projects\danmu_api
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
默认情况下,danmu_api 服务将在 http://localhost:9321 启动。
|
||||
|
||||
### 2. 配置环境变量(可选)
|
||||
|
||||
弹幕请求通过 LunaTV 后端代理转发,需要在后端配置 danmu_api 的地址。
|
||||
|
||||
如果您的 danmu_api 服务运行在不同的地址或使用了自定义 token,可以在项目根目录创建 `.env.local` 文件:
|
||||
|
||||
```env
|
||||
# 弹幕 API 地址(默认: http://localhost:9321)
|
||||
DANMAKU_API_BASE=http://localhost:9321
|
||||
|
||||
# 弹幕 API Token(默认: 87654321)
|
||||
DANMAKU_API_TOKEN=87654321
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 环境变量配置的是服务端地址,不是浏览器端
|
||||
- 如果 danmu_api 和 LunaTV 在同一台机器,使用默认配置即可
|
||||
- 如果在不同机器,请使用完整的 URL(如 `http://192.168.1.100:9321`)
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 弹幕选项卡
|
||||
|
||||
- 位置:视频播放页面,在"选集"和"换源"选项卡之上
|
||||
- 功能:
|
||||
- 搜索动漫弹幕
|
||||
- 浏览搜索结果
|
||||
- 选择剧集并加载弹幕
|
||||
- 显示当前选择的弹幕信息
|
||||
|
||||
### 2. 自动搜索弹幕
|
||||
|
||||
- 视频播放时自动根据视频标题搜索并加载弹幕
|
||||
- 自动记忆上次选择的弹幕源
|
||||
- 根据当前集数智能匹配对应的弹幕
|
||||
|
||||
### 3. 弹幕播放器控制
|
||||
|
||||
在播放器设置菜单中提供以下弹幕控制选项:
|
||||
|
||||
#### 弹幕开关
|
||||
- 一键开启/关闭弹幕显示
|
||||
|
||||
#### 弹幕不透明度
|
||||
- 10%、25%、50%、75%、100% 五档可选
|
||||
- 默认:75%
|
||||
|
||||
#### 弹幕字体大小
|
||||
- 小(20px)
|
||||
- 中(25px,默认)
|
||||
- 大(30px)
|
||||
- 特大(35px)
|
||||
|
||||
#### 弹幕速度
|
||||
- 很慢、慢(默认)、正常、快、很快
|
||||
|
||||
### 4. 弹幕记忆功能
|
||||
|
||||
- 自动记忆每个视频的弹幕选择
|
||||
- 下次播放同一视频时自动加载上次选择的弹幕
|
||||
- 最多保存 100 个视频的弹幕选择记录
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 手动搜索和选择弹幕
|
||||
|
||||
1. 打开视频播放页面
|
||||
2. 点击"弹幕"选项卡
|
||||
3. 在搜索框输入动漫名称
|
||||
4. 点击"搜索"按钮
|
||||
5. 在搜索结果中选择对应的动漫
|
||||
6. 在剧集列表中选择当前集数
|
||||
7. 弹幕会自动加载到播放器
|
||||
|
||||
### 自动搜索弹幕
|
||||
|
||||
- 视频播放时,系统会自动根据视频标题搜索弹幕
|
||||
- 如果找到匹配的弹幕,会自动加载到播放器
|
||||
- 如果有记忆的弹幕选择,会优先使用记忆的选择
|
||||
|
||||
### 控制弹幕显示
|
||||
|
||||
1. 点击播放器右下角的设置按钮
|
||||
2. 在设置菜单中找到"弹幕开关"
|
||||
3. 点击切换弹幕显示/隐藏
|
||||
4. 可调整弹幕不透明度、字体大小、速度等参数
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 架构设计
|
||||
|
||||
弹幕功能采用后端代理模式,所有弹幕请求通过 Next.js API 路由转发到 danmu_api:
|
||||
|
||||
```
|
||||
前端组件 → Next.js API 路由 (代理) → danmu_api 服务 → 返回数据
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 前端不直接暴露 danmu_api 地址
|
||||
- 避免 CORS 跨域问题
|
||||
- 可在后端实现缓存和请求控制
|
||||
- 更好的安全性
|
||||
|
||||
### 集成的文件
|
||||
|
||||
1. **类型定义**
|
||||
- `src/lib/danmaku/types.ts` - 弹幕相关的类型定义
|
||||
|
||||
2. **前端 API 封装**
|
||||
- `src/lib/danmaku/api.ts` - 弹幕 API 客户端(调用本地代理)
|
||||
|
||||
3. **后端代理路由**
|
||||
- `src/app/api/danmaku/search/route.ts` - 搜索动漫代理
|
||||
- `src/app/api/danmaku/episodes/route.ts` - 获取剧集列表代理
|
||||
- `src/app/api/danmaku/comment/route.ts` - 获取弹幕代理
|
||||
- `src/app/api/danmaku/match/route.ts` - 自动匹配代理
|
||||
|
||||
4. **弹幕管理面板**
|
||||
- `src/components/DanmakuPanel.tsx` - 弹幕搜索和选择界面
|
||||
|
||||
5. **修改的文件**
|
||||
- `src/components/EpisodeSelector.tsx` - 添加弹幕选项卡
|
||||
- `src/app/play/page.tsx` - 集成弹幕到播放器
|
||||
|
||||
### 使用的插件
|
||||
|
||||
- `artplayer-plugin-danmuku` - ArtPlayer 官方弹幕插件
|
||||
|
||||
### 弹幕数据流
|
||||
|
||||
```
|
||||
前端: 视频标题 → 调用 /api/danmaku/search
|
||||
↓
|
||||
后端代理: → 转发到 danmu_api
|
||||
↓
|
||||
danmu_api: 搜索弹幕 → 返回动漫列表
|
||||
↓
|
||||
前端: 选择动漫 → 调用 /api/danmaku/episodes
|
||||
↓
|
||||
后端代理: → 转发到 danmu_api
|
||||
↓
|
||||
danmu_api: → 返回剧集列表
|
||||
↓
|
||||
前端: 选择剧集 → 调用 /api/danmaku/comment
|
||||
↓
|
||||
后端代理: → 转发到 danmu_api
|
||||
↓
|
||||
danmu_api: → 返回弹幕数据
|
||||
↓
|
||||
前端: 转换格式 → 加载到播放器 → 显示弹幕
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **danmu_api 服务必须运行**
|
||||
- 弹幕功能依赖 danmu_api 服务
|
||||
- 确保服务在配置的地址和端口上运行
|
||||
- 后端代理会自动转发请求,前端无需配置
|
||||
|
||||
2. **网络连接**
|
||||
- LunaTV 服务器必须能够访问 danmu_api 服务
|
||||
- 如果 danmu_api 在其他机器,确保网络可达
|
||||
- 检查防火墙和端口开放情况
|
||||
|
||||
3. **环境变量配置**
|
||||
- 配置在后端(`.env.local`),不是前端
|
||||
- 修改配置后需要重启 Next.js 服务器
|
||||
- 可参考 `.env.example` 文件
|
||||
|
||||
4. **弹幕数据源**
|
||||
- 弹幕数据来自多个视频平台
|
||||
- 部分视频可能没有匹配的弹幕
|
||||
- 可在 danmu_api 中配置数据源优先级
|
||||
|
||||
5. **性能考虑**
|
||||
- 弹幕数量较多时可能影响性能
|
||||
- 可通过设置菜单调整弹幕显示数量
|
||||
- 后端代理可添加缓存机制(未来实现)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 弹幕无法显示?
|
||||
|
||||
**检查项:**
|
||||
1. danmu_api 服务是否正常运行?
|
||||
2. 浏览器控制台是否有错误信息?
|
||||
3. 是否有匹配的弹幕数据?
|
||||
4. 弹幕开关是否开启?
|
||||
|
||||
### Q2: 弹幕搜索失败?
|
||||
|
||||
**可能原因:**
|
||||
1. danmu_api 服务未启动
|
||||
2. 后端无法连接到 danmu_api(网络问题)
|
||||
3. 环境变量配置错误(检查 `.env.local`)
|
||||
4. 视频标题与弹幕库不匹配
|
||||
|
||||
**调试方法:**
|
||||
1. 检查浏览器 Network 标签,查看 `/api/danmaku/search` 请求
|
||||
2. 检查服务器日志,查看后端转发是否成功
|
||||
3. 直接访问 danmu_api 测试是否正常:`http://localhost:9321/api/v2/search/anime?keyword=测试`
|
||||
|
||||
### Q3: 如何清除弹幕记忆?
|
||||
|
||||
打开浏览器开发者工具,在 Console 中执行:
|
||||
```javascript
|
||||
localStorage.removeItem('danmaku_memories');
|
||||
```
|
||||
|
||||
### Q4: 如何重置弹幕设置?
|
||||
|
||||
在 Console 中执行:
|
||||
```javascript
|
||||
localStorage.removeItem('danmaku_settings');
|
||||
```
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. 支持弹幕发送功能
|
||||
2. 添加弹幕过滤规则编辑界面
|
||||
3. 支持更多弹幕数据源
|
||||
4. 弹幕显示效果优化
|
||||
5. 弹幕高级搜索功能
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [danmu_api 项目](D:\projects\danmu_api)
|
||||
- [ArtPlayer 文档](https://artplayer.org)
|
||||
- [artplayer-plugin-danmuku 文档](https://github.com/zhw2590582/ArtPlayer/tree/master/packages/artplayer-plugin-danmuku)
|
||||
|
||||
---
|
||||
|
||||
**版本**: 1.0
|
||||
**最后更新**: 2025-12-01
|
||||
@@ -1,251 +0,0 @@
|
||||
# 弹幕代理转发实现总结
|
||||
|
||||
## 改进说明
|
||||
|
||||
已将弹幕 API 请求改为通过 LunaTV 后端代理转发,而不是前端直接 fetch。
|
||||
|
||||
**最新更新 (2025-12-01)**: 弹幕配置已从环境变量迁移到管理面板的站点配置中,存储在数据库中。
|
||||
|
||||
## 架构变化
|
||||
|
||||
### 之前的架构
|
||||
```
|
||||
前端浏览器 → 直接 fetch → danmu_api (http://localhost:9321)
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 前端直接暴露 danmu_api 地址
|
||||
- 可能存在 CORS 跨域问题
|
||||
- 无法在后端做统一的缓存和控制
|
||||
|
||||
### 现在的架构
|
||||
```
|
||||
前端浏览器 → Next.js API 路由 (代理) → danmu_api (http://localhost:9321)
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 前端不直接暴露 danmu_api 地址
|
||||
- ✅ 避免 CORS 跨域问题
|
||||
- ✅ 可在后端实现缓存和请求控制
|
||||
- ✅ 更好的安全性
|
||||
- ✅ 统一的错误处理
|
||||
- ✅ 配置存储在数据库中,支持在线修改
|
||||
|
||||
## 实现的 API 路由
|
||||
|
||||
创建了 4 个 Next.js API 路由作为代理:
|
||||
|
||||
1. **`/api/danmaku/search`** (GET)
|
||||
- 搜索动漫
|
||||
- 参数: `keyword`
|
||||
- 转发到: `/api/v2/search/anime`
|
||||
|
||||
2. **`/api/danmaku/episodes`** (GET)
|
||||
- 获取剧集列表
|
||||
- 参数: `animeId`
|
||||
- 转发到: `/api/v2/bangumi/{animeId}`
|
||||
|
||||
3. **`/api/danmaku/comment`** (GET)
|
||||
- 获取弹幕数据
|
||||
- 参数: `episodeId` 或 `url`
|
||||
- 转发到: `/api/v2/comment/{episodeId}?format=xml` 或 `/api/v2/comment?url=...&format=xml`
|
||||
- **特殊处理**: 使用 XML 格式获取完整弹幕数据(避免 JSON 格式丢失数据),后端解析 XML 并转换为 JSON 返回给前端
|
||||
|
||||
4. **`/api/danmaku/match`** (POST)
|
||||
- 自动匹配弹幕
|
||||
- 参数: `fileName`
|
||||
- 转发到: `/api/v2/match`
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### 新增文件
|
||||
```
|
||||
src/app/api/danmaku/search/route.ts # 搜索动漫代理
|
||||
src/app/api/danmaku/episodes/route.ts # 获取剧集代理
|
||||
src/app/api/danmaku/comment/route.ts # 获取弹幕代理
|
||||
src/app/api/danmaku/match/route.ts # 自动匹配代理
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
```
|
||||
src/lib/admin.types.ts # 添加弹幕配置类型
|
||||
src/lib/config.ts # 添加弹幕配置初始化和自检
|
||||
src/app/api/admin/site/route.ts # 添加弹幕配置的保存和读取
|
||||
src/app/admin/page.tsx # 添加弹幕配置 UI
|
||||
src/lib/danmaku/api.ts # 改为调用本地代理 API
|
||||
DANMAKU_INTEGRATION.md # 更新文档说明
|
||||
```
|
||||
|
||||
## 配置方式变化
|
||||
|
||||
### 之前(环境变量)
|
||||
```env
|
||||
# .env.local
|
||||
DANMAKU_API_BASE=http://localhost:9321
|
||||
DANMAKU_API_TOKEN=87654321
|
||||
```
|
||||
|
||||
### 现在(管理面板配置)
|
||||
|
||||
弹幕配置现在存储在数据库的站点配置中,可以通过管理面板进行修改:
|
||||
|
||||
1. 访问管理面板:`/admin`
|
||||
2. 展开"站点配置"标签
|
||||
3. 在"弹幕配置"部分配置以下项:
|
||||
- **弹幕 API 地址**: danmu_api 服务器地址(默认: `http://localhost:9321`)
|
||||
- **弹幕 API Token**: danmu_api 访问令牌(默认: `87654321`)
|
||||
4. 点击"保存"按钮
|
||||
|
||||
**优点**:
|
||||
- ✅ 无需重启服务器即可修改配置
|
||||
- ✅ 配置持久化到数据库
|
||||
- ✅ 支持在线管理
|
||||
- ✅ 更直观的用户界面
|
||||
|
||||
**注意**:环境变量仍可用作初始配置的后备选项,但优先使用数据库中的配置。
|
||||
|
||||
## 代码示例
|
||||
|
||||
### 前端调用(修改后)
|
||||
```typescript
|
||||
// src/lib/danmaku/api.ts
|
||||
|
||||
// 搜索动漫
|
||||
export async function searchAnime(keyword: string) {
|
||||
const url = `/api/danmaku/search?keyword=${encodeURIComponent(keyword)}`;
|
||||
const response = await fetch(url);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 获取弹幕
|
||||
export async function getDanmakuById(episodeId: number) {
|
||||
const url = `/api/danmaku/comment?episodeId=${episodeId}`;
|
||||
const response = await fetch(url);
|
||||
return (await response.json()).comments;
|
||||
}
|
||||
```
|
||||
|
||||
### 后端代理(新增)
|
||||
```typescript
|
||||
// src/app/api/danmaku/search/route.ts
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const keyword = request.nextUrl.searchParams.get('keyword');
|
||||
|
||||
// 转发到 danmu_api
|
||||
const apiUrl = `${DANMAKU_API_BASE}/api/v2/search/anime?keyword=${keyword}`;
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
## 测试验证
|
||||
|
||||
```bash
|
||||
# 类型检查通过
|
||||
pnpm typecheck
|
||||
✓ 无 TypeScript 错误
|
||||
|
||||
# 编译通过
|
||||
pnpm build
|
||||
✓ 构建成功
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 1. 配置弹幕服务(首次使用或更新配置)
|
||||
|
||||
#### 方式一:通过管理面板配置(推荐)
|
||||
|
||||
1. 启动 LunaTV 服务
|
||||
2. 访问管理面板:`http://localhost:3000/admin`
|
||||
3. 展开"站点配置"标签
|
||||
4. 滚动到"弹幕配置"部分
|
||||
5. 设置以下参数:
|
||||
- **弹幕 API 地址**: danmu_api 服务器地址(例如:`http://192.168.1.100:9321`)
|
||||
- **弹幕 API Token**: danmu_api 访问令牌(默认:`87654321`)
|
||||
6. 点击"保存"按钮
|
||||
7. 配置立即生效,无需重启服务器
|
||||
|
||||
#### 方式二:使用环境变量(作为初始配置)
|
||||
|
||||
如果 danmu_api 不在默认地址,创建 `.env.local`:
|
||||
|
||||
```env
|
||||
DANMAKU_API_BASE=http://192.168.1.100:9321
|
||||
DANMAKU_API_TOKEN=your_custom_token
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 环境变量仅在首次初始化时使用
|
||||
- 一旦在管理面板修改配置,将使用数据库中的配置
|
||||
- 环境变量修改需要重启服务器才能生效
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
# 启动 danmu_api(如果使用)
|
||||
cd D:\projects\danmu_api
|
||||
npm start
|
||||
|
||||
# 启动 LunaTV
|
||||
cd D:\projects\LunaTV
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 3. 测试弹幕功能
|
||||
|
||||
1. 打开视频播放页面
|
||||
2. 点击"弹幕"选项卡
|
||||
3. 搜索并选择弹幕
|
||||
4. 弹幕自动加载到播放器
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **配置管理**
|
||||
- **推荐**:使用管理面板进行配置,无需重启服务器
|
||||
- 配置存储在数据库中,持久化保存
|
||||
- 环境变量可用作初始配置的后备选项
|
||||
- 修改配置后立即生效
|
||||
|
||||
2. **网络连接**
|
||||
- LunaTV 服务器必须能访问 danmu_api
|
||||
- 如果在不同机器,确保网络可达
|
||||
- 检查防火墙设置
|
||||
|
||||
3. **调试方法**
|
||||
- 浏览器 Network 标签查看 `/api/danmaku/*` 请求
|
||||
- 服务器日志查看后端转发情况
|
||||
- 直接访问 danmu_api 测试:`http://localhost:9321/api/v2/search/anime?keyword=测试`
|
||||
- 在管理面板查看和修改当前配置
|
||||
|
||||
4. **数据库配置**
|
||||
- 配置存储在 Redis 数据库中
|
||||
- 使用 `NEXT_PUBLIC_STORAGE_TYPE` 环境变量控制存储类型
|
||||
- 如果使用 `localstorage`,将无法在管理面板修改配置
|
||||
|
||||
## 未来改进
|
||||
|
||||
1. **添加缓存机制**
|
||||
- 在后端代理层添加 Redis 缓存
|
||||
- 减少对 danmu_api 的请求压力
|
||||
- 提升响应速度
|
||||
|
||||
2. **添加请求限流**
|
||||
- 防止恶意频繁请求
|
||||
- 保护 danmu_api 服务
|
||||
|
||||
3. **添加错误重试**
|
||||
- 请求失败时自动重试
|
||||
- 提高稳定性
|
||||
|
||||
4. **添加监控和日志**
|
||||
- 记录所有代理请求
|
||||
- 便于排查问题
|
||||
|
||||
---
|
||||
|
||||
**完成时间**: 2025-12-01
|
||||
**状态**: ✅ 已完成并测试通过
|
||||
@@ -7,13 +7,16 @@
|
||||
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎉 相对原版新增内容
|
||||
@@ -297,7 +300,7 @@ NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
||||
## 致谢
|
||||
|
||||
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
|
||||
- [MoonTV](https://github.com/mtvpls/moontvplus)— 由此启发,再次站在巨人的肩膀上。
|
||||
- [MoonTV](https://github.com/MoonTechLab/LunaTV)— 由此启发,再次站在巨人的肩膀上。
|
||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
|
||||
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器。
|
||||
- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持。
|
||||
|
||||
@@ -35,7 +35,7 @@ function VersionDisplay() {
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
window.open('https://github.com/MoonTechLab/LunaTV', '_blank')
|
||||
window.open('https://github.com/mtvpls/MoonTVPlus', '_blank')
|
||||
}
|
||||
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
|
||||
>
|
||||
|
||||
@@ -39,6 +39,7 @@ import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import DoubanComments from '@/components/DoubanComments';
|
||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
@@ -186,6 +187,7 @@ function PlayPageClient() {
|
||||
const [danmakuMatches, setDanmakuMatches] = useState<DanmakuAnime[]>([]);
|
||||
const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false);
|
||||
const [showDanmakuFilterSettings, setShowDanmakuFilterSettings] = useState(false);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
danmakuSettingsRef.current = danmakuSettings;
|
||||
@@ -667,6 +669,9 @@ function PlayPageClient() {
|
||||
const initAnime4K = async () => {
|
||||
if (!artPlayerRef.current?.video) return;
|
||||
|
||||
let frameRequestId: number | null = null; // 在外层声明,以便错误处理中使用
|
||||
let outputCanvas: HTMLCanvasElement | null = null; // 在外层声明,以便错误处理中清理
|
||||
|
||||
try {
|
||||
if (anime4kRef.current) {
|
||||
anime4kRef.current.stop?.();
|
||||
@@ -697,42 +702,138 @@ function PlayPageClient() {
|
||||
throw new Error('无法获取视频尺寸');
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
// 检查视频是否正在播放
|
||||
console.log('视频播放状态:', {
|
||||
paused: video.paused,
|
||||
ended: video.ended,
|
||||
readyState: video.readyState,
|
||||
currentTime: video.currentTime,
|
||||
});
|
||||
|
||||
// 检测是否为Firefox
|
||||
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
|
||||
console.log('浏览器检测:', isFirefox ? 'Firefox' : 'Chrome/Edge/其他');
|
||||
|
||||
// 创建输出canvas(显示给用户的)
|
||||
outputCanvas = document.createElement('canvas');
|
||||
const container = artPlayerRef.current.template.$video.parentElement;
|
||||
|
||||
// 使用用户选择的超分倍数
|
||||
const scale = anime4kScaleRef.current;
|
||||
canvas.width = video.videoWidth * scale;
|
||||
canvas.height = video.videoHeight * scale;
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.objectFit = 'contain';
|
||||
canvas.style.cursor = 'pointer';
|
||||
outputCanvas.width = Math.floor(video.videoWidth * scale); // 确保是整数
|
||||
outputCanvas.height = Math.floor(video.videoHeight * scale);
|
||||
|
||||
// 在canvas上监听点击事件,触发播放器的暂停/播放切换
|
||||
// 验证outputCanvas尺寸
|
||||
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
||||
if (!outputCanvas.width || !outputCanvas.height ||
|
||||
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
|
||||
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
|
||||
}
|
||||
|
||||
outputCanvas.style.position = 'absolute';
|
||||
outputCanvas.style.top = '0';
|
||||
outputCanvas.style.left = '0';
|
||||
outputCanvas.style.width = '100%';
|
||||
outputCanvas.style.height = '100%';
|
||||
outputCanvas.style.objectFit = 'contain';
|
||||
outputCanvas.style.cursor = 'pointer';
|
||||
outputCanvas.style.zIndex = '1';
|
||||
// 确保canvas背景透明,避免Firefox中的渲染问题
|
||||
outputCanvas.style.backgroundColor = 'transparent';
|
||||
|
||||
// Firefox兼容性处理:创建中间canvas
|
||||
let sourceCanvas: HTMLCanvasElement | null = null;
|
||||
let sourceCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
if (isFirefox) {
|
||||
// Firefox的WebGPU不支持直接使用HTMLVideoElement
|
||||
// 使用标准HTMLCanvasElement(更好的兼容性)
|
||||
sourceCanvas = document.createElement('canvas');
|
||||
|
||||
// 获取视频尺寸并记录
|
||||
const videoW = video.videoWidth;
|
||||
const videoH = video.videoHeight;
|
||||
console.log('Firefox:准备创建canvas - 视频尺寸:', videoW, 'x', videoH);
|
||||
|
||||
// 设置canvas尺寸
|
||||
const canvasW = Math.floor(videoW);
|
||||
const canvasH = Math.floor(videoH);
|
||||
console.log('Firefox:计算后的canvas尺寸:', canvasW, 'x', canvasH);
|
||||
|
||||
sourceCanvas.width = canvasW;
|
||||
sourceCanvas.height = canvasH;
|
||||
|
||||
// 立即验证赋值结果
|
||||
console.log('Firefox:Canvas创建后立即检查:');
|
||||
console.log(' - sourceCanvas.width:', sourceCanvas.width);
|
||||
console.log(' - sourceCanvas.height:', sourceCanvas.height);
|
||||
console.log(' - 赋值是否成功:', sourceCanvas.width === canvasW && sourceCanvas.height === canvasH);
|
||||
|
||||
// 验证sourceCanvas尺寸
|
||||
if (!sourceCanvas.width || !sourceCanvas.height ||
|
||||
!isFinite(sourceCanvas.width) || !isFinite(sourceCanvas.height)) {
|
||||
throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`);
|
||||
}
|
||||
|
||||
if (sourceCanvas.width !== canvasW || sourceCanvas.height !== canvasH) {
|
||||
throw new Error(`sourceCanvas尺寸赋值异常: 期望 ${canvasW}x${canvasH}, 实际 ${sourceCanvas.width}x${sourceCanvas.height}`);
|
||||
}
|
||||
|
||||
sourceCtx = sourceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
alpha: false // 禁用alpha通道,提高性能
|
||||
});
|
||||
|
||||
if (!sourceCtx) {
|
||||
throw new Error('无法创建2D上下文');
|
||||
}
|
||||
|
||||
// 先绘制一帧到canvas,确保有内容
|
||||
if (video.readyState >= video.HAVE_CURRENT_DATA) {
|
||||
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||
console.log('Firefox:已绘制初始帧到sourceCanvas');
|
||||
}
|
||||
|
||||
console.log('Firefox检测:使用HTMLCanvasElement中转方案');
|
||||
}
|
||||
|
||||
// 在outputCanvas上监听点击事件,触发播放器的暂停/播放切换
|
||||
const handleCanvasClick = () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.toggle();
|
||||
}
|
||||
};
|
||||
canvas.addEventListener('click', handleCanvasClick);
|
||||
outputCanvas.addEventListener('click', handleCanvasClick);
|
||||
|
||||
// 在canvas上监听双击事件,触发全屏切换
|
||||
// 在outputCanvas上监听双击事件,触发全屏切换
|
||||
const handleCanvasDblClick = () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
|
||||
}
|
||||
};
|
||||
canvas.addEventListener('dblclick', handleCanvasDblClick);
|
||||
outputCanvas.addEventListener('dblclick', handleCanvasDblClick);
|
||||
|
||||
// 隐藏原始video元素
|
||||
video.style.display = 'none';
|
||||
// 隐藏原始video元素(使用opacity而不是display:none以保持视频解码)
|
||||
// Firefox在display:none时可能会停止视频解码,导致黑屏
|
||||
video.style.opacity = '0';
|
||||
video.style.pointerEvents = 'none';
|
||||
video.style.position = 'absolute';
|
||||
video.style.zIndex = '-1';
|
||||
|
||||
// 插入canvas到容器
|
||||
container.insertBefore(canvas, video);
|
||||
// 插入outputCanvas到容器
|
||||
container.insertBefore(outputCanvas, video);
|
||||
|
||||
// Firefox兼容性:创建视频帧捕获循环
|
||||
if (isFirefox && sourceCtx && sourceCanvas) {
|
||||
const captureVideoFrame = () => {
|
||||
if (sourceCtx && sourceCanvas && video.readyState >= video.HAVE_CURRENT_DATA) {
|
||||
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||
}
|
||||
frameRequestId = requestAnimationFrame(captureVideoFrame);
|
||||
};
|
||||
captureVideoFrame();
|
||||
console.log('Firefox:视频帧捕获循环已启动');
|
||||
}
|
||||
|
||||
// 动态导入 anime4k-webgpu 及对应的模式
|
||||
const { render: anime4kRender, ModeA, ModeB, ModeC, ModeAA, ModeBB, ModeCA } = await import('anime4k-webgpu');
|
||||
@@ -764,28 +865,66 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
// 使用anime4k-webgpu的render函数
|
||||
// Firefox使用sourceCanvas,其他浏览器直接使用video
|
||||
const renderConfig: any = {
|
||||
video,
|
||||
canvas,
|
||||
video: isFirefox ? sourceCanvas : video, // Firefox使用canvas中转,其他浏览器直接使用video
|
||||
canvas: outputCanvas,
|
||||
pipelineBuilder: (device: GPUDevice, inputTexture: GPUTexture) => {
|
||||
if (!outputCanvas) {
|
||||
throw new Error('outputCanvas is null in pipelineBuilder');
|
||||
}
|
||||
const mode = new ModeClass({
|
||||
device,
|
||||
inputTexture,
|
||||
nativeDimensions: {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
width: Math.floor(video.videoWidth), // 确保是整数
|
||||
height: Math.floor(video.videoHeight),
|
||||
},
|
||||
targetDimensions: {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
width: Math.floor(outputCanvas.width), // 确保是整数
|
||||
height: Math.floor(outputCanvas.height),
|
||||
},
|
||||
});
|
||||
return [mode];
|
||||
},
|
||||
};
|
||||
|
||||
console.log('开始初始化Anime4K渲染器...');
|
||||
console.log('输入源:', isFirefox ? 'HTMLCanvasElement (Firefox兼容)' : 'video (原生)');
|
||||
console.log('视频尺寸:', video.videoWidth, 'x', video.videoHeight);
|
||||
console.log('输出Canvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
||||
console.log('nativeDimensions:', Math.floor(video.videoWidth), 'x', Math.floor(video.videoHeight));
|
||||
console.log('targetDimensions:', Math.floor(outputCanvas.width), 'x', Math.floor(outputCanvas.height));
|
||||
|
||||
// Firefox调试:检查sourceCanvas状态
|
||||
if (isFirefox && sourceCanvas) {
|
||||
console.log('sourceCanvas详细信息:');
|
||||
console.log(' - width:', sourceCanvas.width, 'height:', sourceCanvas.height);
|
||||
console.log(' - clientWidth:', sourceCanvas.clientWidth, 'clientHeight:', sourceCanvas.clientHeight);
|
||||
console.log(' - offsetWidth:', sourceCanvas.offsetWidth, 'offsetHeight:', sourceCanvas.offsetHeight);
|
||||
|
||||
// 尝试读取一个像素,确认canvas有内容
|
||||
if (sourceCtx) {
|
||||
try {
|
||||
const imageData = sourceCtx.getImageData(0, 0, 1, 1);
|
||||
console.log(' - 像素数据可读:', imageData.data.length > 0);
|
||||
} catch (err) {
|
||||
console.error(' - 无法读取像素数据:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const controller = await anime4kRender(renderConfig);
|
||||
anime4kRef.current = { controller, canvas, handleCanvasClick, handleCanvasDblClick };
|
||||
console.log('Anime4K渲染器初始化成功');
|
||||
|
||||
anime4kRef.current = {
|
||||
controller,
|
||||
canvas: outputCanvas,
|
||||
sourceCanvas: isFirefox ? sourceCanvas : null,
|
||||
frameRequestId: isFirefox ? frameRequestId : null,
|
||||
handleCanvasClick,
|
||||
handleCanvasDblClick,
|
||||
};
|
||||
|
||||
console.log('Anime4K超分已启用,模式:', anime4kModeRef.current, '倍数:', scale);
|
||||
if (artPlayerRef.current) {
|
||||
@@ -796,9 +935,23 @@ function PlayPageClient() {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
|
||||
}
|
||||
|
||||
// 停止帧捕获循环
|
||||
if (frameRequestId) {
|
||||
cancelAnimationFrame(frameRequestId);
|
||||
}
|
||||
|
||||
// 移除outputCanvas(如果已创建)
|
||||
if (outputCanvas && outputCanvas.parentNode) {
|
||||
outputCanvas.parentNode.removeChild(outputCanvas);
|
||||
}
|
||||
|
||||
// 恢复video显示
|
||||
if (artPlayerRef.current?.video) {
|
||||
artPlayerRef.current.video.style.display = 'block';
|
||||
artPlayerRef.current.video.style.opacity = '1';
|
||||
artPlayerRef.current.video.style.pointerEvents = 'auto';
|
||||
artPlayerRef.current.video.style.position = '';
|
||||
artPlayerRef.current.video.style.zIndex = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -807,6 +960,12 @@ function PlayPageClient() {
|
||||
const cleanupAnime4K = async () => {
|
||||
if (anime4kRef.current) {
|
||||
try {
|
||||
// 停止帧捕获循环(仅Firefox)
|
||||
if (anime4kRef.current.frameRequestId) {
|
||||
cancelAnimationFrame(anime4kRef.current.frameRequestId);
|
||||
console.log('Firefox:帧捕获循环已停止');
|
||||
}
|
||||
|
||||
// 停止渲染循环
|
||||
anime4kRef.current.controller?.stop?.();
|
||||
|
||||
@@ -825,11 +984,33 @@ function PlayPageClient() {
|
||||
anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas);
|
||||
}
|
||||
|
||||
// 清理sourceCanvas(仅Firefox)
|
||||
if (anime4kRef.current.sourceCanvas) {
|
||||
if (anime4kRef.current.sourceCanvas instanceof OffscreenCanvas) {
|
||||
// OffscreenCanvas的清理
|
||||
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
|
||||
}
|
||||
console.log('Firefox:OffscreenCanvas已清理');
|
||||
} else {
|
||||
// HTMLCanvasElement的清理
|
||||
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
|
||||
}
|
||||
console.log('Firefox:HTMLCanvasElement已清理');
|
||||
}
|
||||
}
|
||||
|
||||
anime4kRef.current = null;
|
||||
|
||||
// 恢复原始video显示
|
||||
if (artPlayerRef.current?.video) {
|
||||
artPlayerRef.current.video.style.display = 'block';
|
||||
artPlayerRef.current.video.style.opacity = '1';
|
||||
artPlayerRef.current.video.style.pointerEvents = 'auto';
|
||||
artPlayerRef.current.video.style.position = '';
|
||||
artPlayerRef.current.video.style.zIndex = '';
|
||||
}
|
||||
|
||||
console.log('Anime4K已清理');
|
||||
@@ -3397,6 +3578,9 @@ function PlayPageClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast通知 */}
|
||||
{toast && <Toast {...toast} />}
|
||||
|
||||
{/* 弹幕过滤设置对话框 */}
|
||||
<DanmakuFilterSettings
|
||||
isOpen={showDanmakuFilterSettings}
|
||||
@@ -3405,6 +3589,13 @@ function PlayPageClient() {
|
||||
setDanmakuFilterConfig(config);
|
||||
danmakuFilterConfigRef.current = config;
|
||||
}}
|
||||
onShowToast={(message, type) => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -10,12 +10,14 @@ interface DanmakuFilterSettingsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfigUpdate?: (config: DanmakuFilterConfig) => void;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export default function DanmakuFilterSettings({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfigUpdate,
|
||||
onShowToast,
|
||||
}: DanmakuFilterSettingsProps) {
|
||||
const [config, setConfig] = useState<DanmakuFilterConfig>({ rules: [] });
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
@@ -54,10 +56,18 @@ export default function DanmakuFilterSettings({
|
||||
if (onConfigUpdate) {
|
||||
onConfigUpdate(config);
|
||||
}
|
||||
alert('保存成功!');
|
||||
if (onShowToast) {
|
||||
onShowToast('保存成功!', 'success');
|
||||
}
|
||||
// 延迟关闭面板,让用户看到toast
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
console.error('保存弹幕过滤配置失败:', error);
|
||||
alert('保存失败,请重试');
|
||||
if (onShowToast) {
|
||||
onShowToast('保存失败,请重试', 'error');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -66,7 +76,9 @@ export default function DanmakuFilterSettings({
|
||||
// 添加规则
|
||||
const handleAddRule = () => {
|
||||
if (!newKeyword.trim()) {
|
||||
alert('请输入关键字');
|
||||
if (onShowToast) {
|
||||
onShowToast('请输入关键字', 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +117,7 @@ export default function DanmakuFilterSettings({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/70">
|
||||
<div className="bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
|
||||
64
src/components/Toast.tsx
Normal file
64
src/components/Toast.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle, XCircle, Info, X } from 'lucide-react';
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function Toast({ message, type = 'info', duration = 3000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
onClose?.();
|
||||
}, 300); // 等待动画完成
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
onClose?.();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="w-5 h-5" />,
|
||||
error: <XCircle className="w-5 h-5" />,
|
||||
info: <Info className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-500/90',
|
||||
error: 'bg-red-500/90',
|
||||
info: 'bg-blue-500/90',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-20 left-1/2 -translate-x-1/2 z-[9999] transition-all duration-300 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px]`}>
|
||||
<div className="flex-shrink-0">{icons[type]}</div>
|
||||
<div className="flex-1 text-sm font-medium">{message}</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 hover:bg-white/20 rounded p-1 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export enum UpdateStatus {
|
||||
|
||||
// 远程版本检查URL配置
|
||||
const VERSION_CHECK_URLS = [
|
||||
'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/VERSION.txt',
|
||||
'https://raw.githubusercontent.com/mtvpls/MoonTVPlus/main/VERSION.txt',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user