新增弹幕功能
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": ["WebSearch", "Bash(curl:*)"],
|
"allow": [
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 环境变量配置示例
|
||||||
|
# 复制此文件为 .env.local 并修改配置
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 弹幕 API 配置
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 弹幕 API 服务地址(默认: http://localhost:9321)
|
||||||
|
# 用于后端代理转发弹幕请求
|
||||||
|
DANMAKU_API_BASE=http://localhost:9321
|
||||||
|
|
||||||
|
# 弹幕 API Token(默认: 87654321)
|
||||||
|
# 如果您的 danmu_api 使用了自定义 token,请在此配置
|
||||||
|
DANMAKU_API_TOKEN=87654321
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 注意事项
|
||||||
|
# ==========================================
|
||||||
|
# 1. 弹幕请求通过后端代理转发,前端不会直接访问 danmu_api
|
||||||
|
# 2. 确保 danmu_api 服务在配置的地址上正常运行
|
||||||
|
# 3. 如果 danmu_api 和 LunaTV 在同一台机器上,使用 localhost 即可
|
||||||
|
# 4. 如果 danmu_api 在其他机器上,请使用完整的 URL(如 http://192.168.1.100:9321)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 弹幕代理转发实现总结
|
||||||
|
|
||||||
|
## 改进说明
|
||||||
|
|
||||||
|
已将弹幕 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
|
||||||
|
**状态**: ✅ 已完成并测试通过
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"@vidstack/react": "^1.12.13",
|
"@vidstack/react": "^1.12.13",
|
||||||
"anime4k-webgpu": "^1.0.0",
|
"anime4k-webgpu": "^1.0.0",
|
||||||
"artplayer": "^5.2.5",
|
"artplayer": "^5.2.5",
|
||||||
|
"artplayer-plugin-danmuku": "^5.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
|||||||
Generated
+20
@@ -41,6 +41,9 @@ importers:
|
|||||||
artplayer:
|
artplayer:
|
||||||
specifier: ^5.2.5
|
specifier: ^5.2.5
|
||||||
version: 5.2.5
|
version: 5.2.5
|
||||||
|
artplayer-plugin-danmuku:
|
||||||
|
specifier: ^5.2.0
|
||||||
|
version: 5.2.0
|
||||||
bs58:
|
bs58:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -1155,24 +1158,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==}
|
resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==}
|
resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==}
|
resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==}
|
resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==}
|
||||||
@@ -1689,41 +1696,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==}
|
resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==}
|
resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==}
|
resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==}
|
resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==}
|
resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==}
|
resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==}
|
resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==}
|
resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==}
|
resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==}
|
||||||
@@ -1998,6 +2013,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-iHHSGEc0J+gl7imX4nmLqqC7LXmv8xeGf2lUevhhyUCoiCRZplecpKQICHjErVb1Yqyp1xCy6Sp8eYN+EpFWFw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ogym5rvkAJ4VLncM4Apl3TJ/a/ozM3csvY4IKuuMR++hUmEZgj/HaGsNonwx8r56nsqiZYE7O4vS1HFZl+NBSg==}
|
resolution: {integrity: sha512-Ogym5rvkAJ4VLncM4Apl3TJ/a/ozM3csvY4IKuuMR++hUmEZgj/HaGsNonwx8r56nsqiZYE7O4vS1HFZl+NBSg==}
|
||||||
|
|
||||||
@@ -7496,6 +7514,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
option-validator: 2.0.6
|
option-validator: 2.0.6
|
||||||
|
|||||||
+57
-1
@@ -267,6 +267,8 @@ interface SiteConfig {
|
|||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
DanmakuApiBase: string;
|
||||||
|
DanmakuApiToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频源数据类型
|
// 视频源数据类型
|
||||||
@@ -3393,6 +3395,8 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
DoubanImageProxy: '',
|
DoubanImageProxy: '',
|
||||||
DisableYellowFilter: false,
|
DisableYellowFilter: false,
|
||||||
FluidSearch: true,
|
FluidSearch: true,
|
||||||
|
DanmakuApiBase: 'http://localhost:9321',
|
||||||
|
DanmakuApiToken: '87654321',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 豆瓣数据源相关状态
|
// 豆瓣数据源相关状态
|
||||||
@@ -3455,6 +3459,8 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||||
|
DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||||
|
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
@@ -3903,10 +3909,60 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
启用后搜索结果将实时流式返回,提升用户体验。
|
启用后搜索结果将实时流式返回,提升用户体验。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 弹幕 API 配置 */}
|
||||||
|
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
弹幕配置
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 弹幕 API 地址 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
弹幕 API 地址
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='http://localhost:9321'
|
||||||
|
value={siteSettings.DanmakuApiBase}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
DanmakuApiBase: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
弹幕服务器的 API 地址,默认为 http://localhost:9321
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 弹幕 API Token */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
弹幕 API Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='87654321'
|
||||||
|
value={siteSettings.DanmakuApiToken}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
DanmakuApiToken: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
弹幕服务器的访问令牌,默认为 87654321
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
|
|||||||
DoubanImageProxy,
|
DoubanImageProxy,
|
||||||
DisableYellowFilter,
|
DisableYellowFilter,
|
||||||
FluidSearch,
|
FluidSearch,
|
||||||
|
DanmakuApiBase,
|
||||||
|
DanmakuApiToken,
|
||||||
} = body as {
|
} = body as {
|
||||||
SiteName: string;
|
SiteName: string;
|
||||||
Announcement: string;
|
Announcement: string;
|
||||||
@@ -50,6 +52,8 @@ export async function POST(request: NextRequest) {
|
|||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
DanmakuApiBase: string;
|
||||||
|
DanmakuApiToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验
|
||||||
@@ -63,7 +67,9 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof DoubanImageProxyType !== 'string' ||
|
typeof DoubanImageProxyType !== 'string' ||
|
||||||
typeof DoubanImageProxy !== 'string' ||
|
typeof DoubanImageProxy !== 'string' ||
|
||||||
typeof DisableYellowFilter !== 'boolean' ||
|
typeof DisableYellowFilter !== 'boolean' ||
|
||||||
typeof FluidSearch !== 'boolean'
|
typeof FluidSearch !== 'boolean' ||
|
||||||
|
typeof DanmakuApiBase !== 'string' ||
|
||||||
|
typeof DanmakuApiToken !== 'string'
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -93,6 +99,8 @@ export async function POST(request: NextRequest) {
|
|||||||
DoubanImageProxy,
|
DoubanImageProxy,
|
||||||
DisableYellowFilter,
|
DisableYellowFilter,
|
||||||
FluidSearch,
|
FluidSearch,
|
||||||
|
DanmakuApiBase,
|
||||||
|
DanmakuApiToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// 获取弹幕 API 路由
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// 解析弹幕 XML 为 JSON
|
||||||
|
function parseXmlDanmaku(xmlText: string): Array<{ p: string; m: string; cid: number }> {
|
||||||
|
const comments: Array<{ p: string; m: string; cid: number }> = [];
|
||||||
|
|
||||||
|
// 使用正则表达式提取所有 <d> 标签
|
||||||
|
const dTagRegex = /<d\s+p="([^"]+)"[^>]*>([^<]*)<\/d>/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = dTagRegex.exec(xmlText)) !== null) {
|
||||||
|
const p = match[1];
|
||||||
|
const m = match[2];
|
||||||
|
|
||||||
|
// 从 p 属性中提取 cid(弹幕ID)
|
||||||
|
const pParts = p.split(',');
|
||||||
|
const cid = pParts[7] ? parseInt(pParts[7]) : 0;
|
||||||
|
|
||||||
|
comments.push({
|
||||||
|
p,
|
||||||
|
m,
|
||||||
|
cid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const episodeId = searchParams.get('episodeId');
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
|
||||||
|
// 至少需要一个参数
|
||||||
|
if (!episodeId && !url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
count: 0,
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库读取弹幕配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||||
|
|
||||||
|
// 构建 API URL
|
||||||
|
const baseUrl =
|
||||||
|
DanmakuApiToken === '87654321'
|
||||||
|
? DanmakuApiBase
|
||||||
|
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||||
|
|
||||||
|
let apiUrl: string;
|
||||||
|
|
||||||
|
if (episodeId) {
|
||||||
|
// 通过剧集 ID 获取弹幕 - 使用 XML 格式
|
||||||
|
apiUrl = `${baseUrl}/api/v2/comment/${episodeId}?format=xml`;
|
||||||
|
} else {
|
||||||
|
// 通过视频 URL 获取弹幕 - 使用 XML 格式
|
||||||
|
apiUrl = `${baseUrl}/api/v2/comment?url=${encodeURIComponent(url!)}&format=xml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/xml, text/xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 XML 文本
|
||||||
|
const xmlText = await response.text();
|
||||||
|
|
||||||
|
// 解析 XML 为 JSON
|
||||||
|
const comments = parseXmlDanmaku(xmlText);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
count: comments.length,
|
||||||
|
comments,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取弹幕代理错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
count: 0,
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// 获取剧集列表 API 路由
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const animeId = searchParams.get('animeId');
|
||||||
|
|
||||||
|
if (!animeId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: '缺少动漫ID参数',
|
||||||
|
bangumi: {
|
||||||
|
bangumiId: '',
|
||||||
|
animeTitle: '',
|
||||||
|
episodes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库读取弹幕配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||||
|
|
||||||
|
// 构建 API URL
|
||||||
|
const baseUrl =
|
||||||
|
DanmakuApiToken === '87654321'
|
||||||
|
? DanmakuApiBase
|
||||||
|
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||||
|
|
||||||
|
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取剧集列表代理错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage:
|
||||||
|
error instanceof Error ? error.message : '获取剧集列表失败',
|
||||||
|
bangumi: {
|
||||||
|
bangumiId: '',
|
||||||
|
animeTitle: '',
|
||||||
|
episodes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 自动匹配 API 路由
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { fileName } = body;
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: '缺少文件名参数',
|
||||||
|
isMatched: false,
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库读取弹幕配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||||
|
|
||||||
|
// 构建 API URL
|
||||||
|
const baseUrl =
|
||||||
|
DanmakuApiToken === '87654321'
|
||||||
|
? DanmakuApiBase
|
||||||
|
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||||
|
|
||||||
|
const apiUrl = `${baseUrl}/api/v2/match`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ fileName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('自动匹配代理错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '匹配失败',
|
||||||
|
isMatched: false,
|
||||||
|
matches: [],
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 弹幕搜索 API 路由
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const keyword = searchParams.get('keyword');
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: '缺少关键词参数',
|
||||||
|
animes: [],
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库读取弹幕配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||||
|
|
||||||
|
// 构建 API URL
|
||||||
|
const baseUrl =
|
||||||
|
DanmakuApiToken === '87654321'
|
||||||
|
? DanmakuApiBase
|
||||||
|
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||||
|
|
||||||
|
const apiUrl = `${baseUrl}/api/v2/search/anime?keyword=${encodeURIComponent(keyword)}`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('弹幕搜索代理错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '搜索失败',
|
||||||
|
animes: [],
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+398
-18
@@ -3,6 +3,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Artplayer from 'artplayer';
|
import Artplayer from 'artplayer';
|
||||||
|
import artplayerPluginDanmuku from 'artplayer-plugin-danmuku';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import { Heart } from 'lucide-react';
|
import { Heart } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
@@ -23,6 +24,17 @@ import {
|
|||||||
saveSkipConfig,
|
saveSkipConfig,
|
||||||
subscribeToDataUpdates,
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
|
import {
|
||||||
|
convertDanmakuFormat,
|
||||||
|
getDanmakuById,
|
||||||
|
getEpisodes,
|
||||||
|
loadDanmakuMemory,
|
||||||
|
loadDanmakuSettings,
|
||||||
|
saveDanmakuMemory,
|
||||||
|
saveDanmakuSettings,
|
||||||
|
searchAnime,
|
||||||
|
} from '@/lib/danmaku/api';
|
||||||
|
import type { DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -153,6 +165,23 @@ function PlayPageClient() {
|
|||||||
checkWebGPUSupport();
|
checkWebGPUSupport();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 弹幕相关状态
|
||||||
|
const [danmakuSettings, setDanmakuSettings] = useState<DanmakuSettings>(
|
||||||
|
loadDanmakuSettings()
|
||||||
|
);
|
||||||
|
const [currentDanmakuSelection, setCurrentDanmakuSelection] =
|
||||||
|
useState<DanmakuSelection | null>(null);
|
||||||
|
const [danmakuEpisodesList, setDanmakuEpisodesList] = useState<
|
||||||
|
Array<{ episodeId: number; episodeTitle: string }>
|
||||||
|
>([]);
|
||||||
|
const [danmakuLoading, setDanmakuLoading] = useState(false);
|
||||||
|
const danmakuPluginRef = useRef<any>(null);
|
||||||
|
const danmakuSettingsRef = useRef(danmakuSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
danmakuSettingsRef.current = danmakuSettings;
|
||||||
|
}, [danmakuSettings]);
|
||||||
|
|
||||||
// 视频基本信息
|
// 视频基本信息
|
||||||
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
|
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
|
||||||
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
|
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
|
||||||
@@ -203,6 +232,31 @@ function PlayPageClient() {
|
|||||||
videoYear,
|
videoYear,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 监听剧集切换,自动加载对应的弹幕
|
||||||
|
useEffect(() => {
|
||||||
|
// 只有在有弹幕选择且有剧集列表时才自动切换
|
||||||
|
if (
|
||||||
|
currentDanmakuSelection &&
|
||||||
|
danmakuEpisodesList.length > 0 &&
|
||||||
|
currentEpisodeIndex < danmakuEpisodesList.length
|
||||||
|
) {
|
||||||
|
const episode = danmakuEpisodesList[currentEpisodeIndex];
|
||||||
|
if (episode && episode.episodeId !== currentDanmakuSelection.episodeId) {
|
||||||
|
// 自动加载新集数的弹幕
|
||||||
|
const newSelection: DanmakuSelection = {
|
||||||
|
animeId: currentDanmakuSelection.animeId,
|
||||||
|
episodeId: episode.episodeId,
|
||||||
|
animeTitle: currentDanmakuSelection.animeTitle,
|
||||||
|
episodeTitle: episode.episodeTitle,
|
||||||
|
};
|
||||||
|
setCurrentDanmakuSelection(newSelection);
|
||||||
|
loadDanmaku(episode.episodeId);
|
||||||
|
console.log(`自动切换弹幕到第 ${currentEpisodeIndex + 1} 集`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentEpisodeIndex]);
|
||||||
|
|
||||||
|
|
||||||
// 视频播放地址
|
// 视频播放地址
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
@@ -1091,24 +1145,10 @@ function PlayPageClient() {
|
|||||||
setLoadingStage('ready');
|
setLoadingStage('ready');
|
||||||
setLoadingMessage('✨ 准备就绪,即将开始播放...');
|
setLoadingMessage('✨ 准备就绪,即将开始播放...');
|
||||||
|
|
||||||
// 短暂延迟让用户看到完成状态
|
// 加载播放记录
|
||||||
setTimeout(() => {
|
|
||||||
setLoading(false);
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
initAll();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 播放记录处理
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅在初次挂载时检查播放记录
|
|
||||||
const initFromHistory = async () => {
|
|
||||||
if (!currentSource || !currentId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allRecords = await getAllPlayRecords();
|
const allRecords = await getAllPlayRecords();
|
||||||
const key = generateStorageKey(currentSource, currentId);
|
const key = generateStorageKey(detailData.source, detailData.id);
|
||||||
const record = allRecords[key];
|
const record = allRecords[key];
|
||||||
|
|
||||||
if (record) {
|
if (record) {
|
||||||
@@ -1116,8 +1156,9 @@ function PlayPageClient() {
|
|||||||
const targetTime = record.play_time;
|
const targetTime = record.play_time;
|
||||||
|
|
||||||
// 更新当前选集索引
|
// 更新当前选集索引
|
||||||
if (targetIndex !== currentEpisodeIndex) {
|
if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
|
||||||
setCurrentEpisodeIndex(targetIndex);
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
|
currentEpisodeIndexRef.current = targetIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存待恢复的播放进度,待播放器就绪后跳转
|
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||||||
@@ -1126,9 +1167,14 @@ function PlayPageClient() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('读取播放记录失败:', err);
|
console.error('读取播放记录失败:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 短暂延迟让用户看到完成状态
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
initFromHistory();
|
initAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 跳过片头片尾配置处理
|
// 跳过片头片尾配置处理
|
||||||
@@ -1282,6 +1328,175 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 弹幕处理函数
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 加载弹幕到播放器
|
||||||
|
const loadDanmaku = async (episodeId: number) => {
|
||||||
|
if (!danmakuPluginRef.current) {
|
||||||
|
console.warn('弹幕插件未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDanmakuLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先清空当前弹幕
|
||||||
|
danmakuPluginRef.current.config({
|
||||||
|
danmuku: [],
|
||||||
|
});
|
||||||
|
danmakuPluginRef.current.load();
|
||||||
|
|
||||||
|
// 获取弹幕数据
|
||||||
|
const comments = await getDanmakuById(episodeId);
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
console.warn('未获取到弹幕数据');
|
||||||
|
setDanmakuLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换弹幕格式
|
||||||
|
const danmakuData = convertDanmakuFormat(comments);
|
||||||
|
|
||||||
|
// 加载弹幕到插件
|
||||||
|
danmakuPluginRef.current.config({
|
||||||
|
danmuku: danmakuData,
|
||||||
|
});
|
||||||
|
danmakuPluginRef.current.load();
|
||||||
|
|
||||||
|
console.log(`弹幕加载成功,共 ${comments.length} 条`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载弹幕失败:', error);
|
||||||
|
} finally {
|
||||||
|
setDanmakuLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理弹幕选择
|
||||||
|
const handleDanmakuSelect = async (selection: DanmakuSelection) => {
|
||||||
|
setCurrentDanmakuSelection(selection);
|
||||||
|
|
||||||
|
// 保存选择记忆
|
||||||
|
saveDanmakuMemory(
|
||||||
|
videoTitleRef.current,
|
||||||
|
selection.animeId,
|
||||||
|
selection.episodeId,
|
||||||
|
selection.animeTitle,
|
||||||
|
selection.episodeTitle
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取该动漫的所有剧集列表
|
||||||
|
try {
|
||||||
|
const episodesResult = await getEpisodes(selection.animeId);
|
||||||
|
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
|
||||||
|
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取弹幕剧集列表失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载弹幕
|
||||||
|
await loadDanmaku(selection.episodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动搜索并加载弹幕
|
||||||
|
const autoSearchDanmaku = async () => {
|
||||||
|
const title = videoTitleRef.current;
|
||||||
|
if (!title) {
|
||||||
|
console.warn('视频标题为空,无法自动搜索弹幕');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有记忆
|
||||||
|
const memory = loadDanmakuMemory(title);
|
||||||
|
if (memory) {
|
||||||
|
console.log('使用记忆的弹幕选择:', memory);
|
||||||
|
setCurrentDanmakuSelection({
|
||||||
|
animeId: memory.animeId,
|
||||||
|
episodeId: memory.episodeId,
|
||||||
|
animeTitle: memory.animeTitle,
|
||||||
|
episodeTitle: memory.episodeTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取该动漫的所有剧集列表
|
||||||
|
try {
|
||||||
|
const episodesResult = await getEpisodes(memory.animeId);
|
||||||
|
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
|
||||||
|
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取弹幕剧集列表失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDanmaku(memory.episodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动搜索弹幕
|
||||||
|
setDanmakuLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResult = await searchAnime(title);
|
||||||
|
|
||||||
|
if (searchResult.success && searchResult.animes.length > 0) {
|
||||||
|
// 使用第一个搜索结果
|
||||||
|
const anime = searchResult.animes[0];
|
||||||
|
|
||||||
|
// 获取剧集列表
|
||||||
|
const episodesResult = await getEpisodes(anime.animeId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
episodesResult.success &&
|
||||||
|
episodesResult.bangumi.episodes.length > 0
|
||||||
|
) {
|
||||||
|
// 保存剧集列表
|
||||||
|
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
|
||||||
|
|
||||||
|
// 根据当前集数选择对应的弹幕
|
||||||
|
const currentEp = currentEpisodeIndexRef.current;
|
||||||
|
const episode =
|
||||||
|
episodesResult.bangumi.episodes[
|
||||||
|
Math.min(currentEp, episodesResult.bangumi.episodes.length - 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (episode) {
|
||||||
|
const selection: DanmakuSelection = {
|
||||||
|
animeId: anime.animeId,
|
||||||
|
episodeId: episode.episodeId,
|
||||||
|
animeTitle: anime.animeTitle,
|
||||||
|
episodeTitle: episode.episodeTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCurrentDanmakuSelection(selection);
|
||||||
|
|
||||||
|
// 保存选择记忆
|
||||||
|
saveDanmakuMemory(
|
||||||
|
title,
|
||||||
|
selection.animeId,
|
||||||
|
selection.episodeId,
|
||||||
|
selection.animeTitle,
|
||||||
|
selection.episodeTitle
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载弹幕
|
||||||
|
await loadDanmaku(episode.episodeId);
|
||||||
|
|
||||||
|
console.log('自动搜索弹幕成功:', selection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('未找到剧集信息');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('未找到匹配的弹幕');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('自动搜索弹幕失败:', error);
|
||||||
|
} finally {
|
||||||
|
setDanmakuLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 键盘快捷键
|
// 键盘快捷键
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1676,6 +1891,35 @@ function PlayPageClient() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 弹幕插件
|
||||||
|
plugins: [
|
||||||
|
artplayerPluginDanmuku({
|
||||||
|
danmuku: [],
|
||||||
|
speed: danmakuSettingsRef.current.speed,
|
||||||
|
opacity: danmakuSettingsRef.current.opacity,
|
||||||
|
fontSize: danmakuSettingsRef.current.fontSize,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
mode: 0,
|
||||||
|
margin: [danmakuSettingsRef.current.marginTop, danmakuSettingsRef.current.marginBottom],
|
||||||
|
antiOverlap: true,
|
||||||
|
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
||||||
|
filter: (danmu: any) => {
|
||||||
|
// 应用过滤规则
|
||||||
|
if (danmakuSettingsRef.current.filterRules.length > 0) {
|
||||||
|
for (const rule of danmakuSettingsRef.current.filterRules) {
|
||||||
|
try {
|
||||||
|
if (new RegExp(rule).test(danmu.text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('弹幕过滤规则错误:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
icons: {
|
icons: {
|
||||||
loading:
|
loading:
|
||||||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||||
@@ -1707,6 +1951,115 @@ function PlayPageClient() {
|
|||||||
return newVal ? '当前开启' : '当前关闭';
|
return newVal ? '当前开启' : '当前关闭';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 弹幕开关
|
||||||
|
{
|
||||||
|
name: '弹幕开关',
|
||||||
|
html: '弹幕开关',
|
||||||
|
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" fill="#ffffff"/><text x="12" y="13" font-size="8" text-anchor="middle" fill="#ffffff">弹</text></svg>',
|
||||||
|
switch: danmakuSettingsRef.current.enabled,
|
||||||
|
onSwitch: function (item: any) {
|
||||||
|
const newSettings = {
|
||||||
|
...danmakuSettingsRef.current,
|
||||||
|
enabled: !item.switch,
|
||||||
|
};
|
||||||
|
setDanmakuSettings(newSettings);
|
||||||
|
saveDanmakuSettings(newSettings);
|
||||||
|
|
||||||
|
// 切换弹幕显示/隐藏
|
||||||
|
if (danmakuPluginRef.current) {
|
||||||
|
if (newSettings.enabled) {
|
||||||
|
danmakuPluginRef.current.show();
|
||||||
|
} else {
|
||||||
|
danmakuPluginRef.current.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !item.switch;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 弹幕不透明度
|
||||||
|
{
|
||||||
|
name: '弹幕不透明度',
|
||||||
|
html: '弹幕不透明度',
|
||||||
|
selector: [
|
||||||
|
{ html: '10%', value: '0.1' },
|
||||||
|
{ html: '25%', value: '0.25' },
|
||||||
|
{ html: '50%', value: '0.5' },
|
||||||
|
{ html: '75%', value: '0.75', default: true },
|
||||||
|
{ html: '100%', value: '1.0' },
|
||||||
|
],
|
||||||
|
onSelect: function (item: any) {
|
||||||
|
const opacity = parseFloat(item.value);
|
||||||
|
const newSettings = {
|
||||||
|
...danmakuSettingsRef.current,
|
||||||
|
opacity,
|
||||||
|
};
|
||||||
|
setDanmakuSettings(newSettings);
|
||||||
|
saveDanmakuSettings(newSettings);
|
||||||
|
|
||||||
|
// 更新弹幕插件配置
|
||||||
|
if (danmakuPluginRef.current) {
|
||||||
|
danmakuPluginRef.current.config({ opacity });
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 弹幕字体大小
|
||||||
|
{
|
||||||
|
name: '弹幕字体大小',
|
||||||
|
html: '弹幕字体大小',
|
||||||
|
selector: [
|
||||||
|
{ html: '小', value: '20' },
|
||||||
|
{ html: '中', value: '25', default: true },
|
||||||
|
{ html: '大', value: '30' },
|
||||||
|
{ html: '特大', value: '35' },
|
||||||
|
],
|
||||||
|
onSelect: function (item: any) {
|
||||||
|
const fontSize = parseInt(item.value);
|
||||||
|
const newSettings = {
|
||||||
|
...danmakuSettingsRef.current,
|
||||||
|
fontSize,
|
||||||
|
};
|
||||||
|
setDanmakuSettings(newSettings);
|
||||||
|
saveDanmakuSettings(newSettings);
|
||||||
|
|
||||||
|
// 更新弹幕插件配置
|
||||||
|
if (danmakuPluginRef.current) {
|
||||||
|
danmakuPluginRef.current.config({ fontSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 弹幕速度
|
||||||
|
{
|
||||||
|
name: '弹幕速度',
|
||||||
|
html: '弹幕速度',
|
||||||
|
selector: [
|
||||||
|
{ html: '很慢', value: '3' },
|
||||||
|
{ html: '慢', value: '5', default: true },
|
||||||
|
{ html: '正常', value: '7' },
|
||||||
|
{ html: '快', value: '10' },
|
||||||
|
{ html: '很快', value: '15' },
|
||||||
|
],
|
||||||
|
onSelect: function (item: any) {
|
||||||
|
const speed = parseInt(item.value);
|
||||||
|
const newSettings = {
|
||||||
|
...danmakuSettingsRef.current,
|
||||||
|
speed,
|
||||||
|
};
|
||||||
|
setDanmakuSettings(newSettings);
|
||||||
|
saveDanmakuSettings(newSettings);
|
||||||
|
|
||||||
|
// 更新弹幕插件配置
|
||||||
|
if (danmakuPluginRef.current) {
|
||||||
|
danmakuPluginRef.current.config({ speed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.html;
|
||||||
|
},
|
||||||
|
},
|
||||||
...(webGPUSupported ? [
|
...(webGPUSupported ? [
|
||||||
{
|
{
|
||||||
name: 'Anime4K超分',
|
name: 'Anime4K超分',
|
||||||
@@ -1877,6 +2230,21 @@ function PlayPageClient() {
|
|||||||
artPlayerRef.current.on('ready', async () => {
|
artPlayerRef.current.on('ready', async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// 保存弹幕插件引用
|
||||||
|
if (artPlayerRef.current?.plugins?.artplayerPluginDanmuku) {
|
||||||
|
danmakuPluginRef.current = artPlayerRef.current.plugins.artplayerPluginDanmuku;
|
||||||
|
|
||||||
|
// 根据设置显示或隐藏弹幕
|
||||||
|
if (danmakuSettingsRef.current.enabled) {
|
||||||
|
danmakuPluginRef.current.show();
|
||||||
|
} else {
|
||||||
|
danmakuPluginRef.current.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动搜索并加载弹幕
|
||||||
|
await autoSearchDanmaku();
|
||||||
|
}
|
||||||
|
|
||||||
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
||||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||||
requestWakeLock();
|
requestWakeLock();
|
||||||
@@ -2334,6 +2702,16 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 弹幕加载蒙层 */}
|
||||||
|
{danmakuLoading && (
|
||||||
|
<div className='absolute top-0 right-0 m-4 bg-black/80 backdrop-blur-sm rounded-lg px-4 py-2 z-[600] flex items-center gap-2 border border-green-500/30'>
|
||||||
|
<div className='w-4 h-4 border-2 border-green-500 border-t-transparent rounded-full animate-spin'></div>
|
||||||
|
<span className='text-sm font-medium text-green-400'>
|
||||||
|
加载弹幕中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第三方应用打开按钮 */}
|
{/* 第三方应用打开按钮 */}
|
||||||
@@ -2547,6 +2925,8 @@ function PlayPageClient() {
|
|||||||
sourceSearchLoading={sourceSearchLoading}
|
sourceSearchLoading={sourceSearchLoading}
|
||||||
sourceSearchError={sourceSearchError}
|
sourceSearchError={sourceSearchError}
|
||||||
precomputedVideoInfo={precomputedVideoInfo}
|
precomputedVideoInfo={precomputedVideoInfo}
|
||||||
|
onDanmakuSelect={handleDanmakuSelect}
|
||||||
|
currentDanmakuSelection={currentDanmakuSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { getEpisodes, searchAnime } from '@/lib/danmaku/api';
|
||||||
|
import type {
|
||||||
|
DanmakuAnime,
|
||||||
|
DanmakuEpisode,
|
||||||
|
DanmakuSelection,
|
||||||
|
} from '@/lib/danmaku/types';
|
||||||
|
|
||||||
|
interface DanmakuPanelProps {
|
||||||
|
videoTitle: string;
|
||||||
|
currentEpisodeIndex: number;
|
||||||
|
onDanmakuSelect: (selection: DanmakuSelection) => void;
|
||||||
|
currentSelection: DanmakuSelection | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DanmakuPanel({
|
||||||
|
videoTitle,
|
||||||
|
currentEpisodeIndex,
|
||||||
|
onDanmakuSelect,
|
||||||
|
currentSelection,
|
||||||
|
}: DanmakuPanelProps) {
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<DanmakuAnime[]>([]);
|
||||||
|
const [selectedAnime, setSelectedAnime] = useState<DanmakuAnime | null>(null);
|
||||||
|
const [episodes, setEpisodes] = useState<DanmakuEpisode[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 搜索弹幕
|
||||||
|
const handleSearch = useCallback(async (keyword: string) => {
|
||||||
|
if (!keyword.trim()) {
|
||||||
|
setSearchError('请输入搜索关键词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchAnime(keyword.trim());
|
||||||
|
|
||||||
|
if (response.success && response.animes.length > 0) {
|
||||||
|
setSearchResults(response.animes);
|
||||||
|
setSearchError(null);
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchError(
|
||||||
|
response.errorMessage || '未找到匹配的动漫,请尝试其他关键词'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error);
|
||||||
|
setSearchError('搜索失败,请检查弹幕 API 服务是否正常运行');
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 选择动漫,加载剧集列表
|
||||||
|
const handleAnimeSelect = useCallback(async (anime: DanmakuAnime) => {
|
||||||
|
setSelectedAnime(anime);
|
||||||
|
setIsLoadingEpisodes(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getEpisodes(anime.animeId);
|
||||||
|
|
||||||
|
if (response.success && response.bangumi.episodes.length > 0) {
|
||||||
|
setEpisodes(response.bangumi.episodes);
|
||||||
|
} else {
|
||||||
|
setEpisodes([]);
|
||||||
|
setSearchError('该动漫暂无剧集信息');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取剧集失败:', error);
|
||||||
|
setEpisodes([]);
|
||||||
|
setSearchError('获取剧集失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingEpisodes(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 选择剧集
|
||||||
|
const handleEpisodeSelect = useCallback(
|
||||||
|
(episode: DanmakuEpisode) => {
|
||||||
|
if (!selectedAnime) return;
|
||||||
|
|
||||||
|
const selection: DanmakuSelection = {
|
||||||
|
animeId: selectedAnime.animeId,
|
||||||
|
episodeId: episode.episodeId,
|
||||||
|
animeTitle: selectedAnime.animeTitle,
|
||||||
|
episodeTitle: episode.episodeTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
onDanmakuSelect(selection);
|
||||||
|
},
|
||||||
|
[selectedAnime, onDanmakuSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 回到搜索结果
|
||||||
|
const handleBackToResults = useCallback(() => {
|
||||||
|
setSelectedAnime(null);
|
||||||
|
setEpisodes([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 判断当前剧集是否已选中
|
||||||
|
const isEpisodeSelected = useCallback(
|
||||||
|
(episodeId: number) => {
|
||||||
|
return currentSelection?.episodeId === episodeId;
|
||||||
|
},
|
||||||
|
[currentSelection]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当视频标题变化时,更新搜索关键词
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoTitle && !searchKeyword) {
|
||||||
|
setSearchKeyword(videoTitle);
|
||||||
|
}
|
||||||
|
}, [videoTitle, searchKeyword]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-col'>
|
||||||
|
{/* 搜索区域 */}
|
||||||
|
<div className='mb-4 flex-shrink-0'>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch(searchKeyword);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder='输入动漫名称搜索弹幕...'
|
||||||
|
className='flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm
|
||||||
|
transition-colors focus:border-green-500 focus:outline-none
|
||||||
|
focus:ring-2 focus:ring-green-500/20
|
||||||
|
dark:border-gray-600 dark:bg-gray-800 dark:text-white'
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch(searchKeyword)}
|
||||||
|
disabled={isSearching}
|
||||||
|
className='flex items-center gap-2 rounded-lg bg-green-500 px-4 py-2
|
||||||
|
text-sm font-medium text-white transition-colors
|
||||||
|
hover:bg-green-600 disabled:cursor-not-allowed
|
||||||
|
disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700'
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className='h-4 w-4' />
|
||||||
|
{isSearching ? '搜索中...' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前选择的弹幕信息 */}
|
||||||
|
{currentSelection && (
|
||||||
|
<div
|
||||||
|
className='mt-3 rounded-lg border border-green-500/30 bg-green-500/10
|
||||||
|
px-3 py-2 text-sm'
|
||||||
|
>
|
||||||
|
<p className='font-semibold text-green-600 dark:text-green-400'>
|
||||||
|
当前弹幕
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-gray-700 dark:text-gray-300'>
|
||||||
|
{currentSelection.animeTitle}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-gray-600 dark:text-gray-400'>
|
||||||
|
{currentSelection.episodeTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{searchError && (
|
||||||
|
<div
|
||||||
|
className='mt-3 rounded-lg border border-red-500/30 bg-red-500/10
|
||||||
|
px-3 py-2 text-sm text-red-600 dark:text-red-400'
|
||||||
|
>
|
||||||
|
{searchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className='flex-1 overflow-y-auto'>
|
||||||
|
{/* 显示剧集列表 */}
|
||||||
|
{selectedAnime && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{/* 返回按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleBackToResults}
|
||||||
|
className='mb-2 text-sm text-green-600 hover:underline
|
||||||
|
dark:text-green-400'
|
||||||
|
>
|
||||||
|
← 返回搜索结果
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 动漫标题 */}
|
||||||
|
<h3 className='mb-3 text-base font-semibold text-gray-800 dark:text-white'>
|
||||||
|
{selectedAnime.animeTitle}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 加载中 */}
|
||||||
|
{isLoadingEpisodes && (
|
||||||
|
<div className='flex items-center justify-center py-8'>
|
||||||
|
<div
|
||||||
|
className='h-8 w-8 animate-spin rounded-full border-4
|
||||||
|
border-gray-300 border-t-green-500'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 剧集网格 */}
|
||||||
|
{!isLoadingEpisodes && episodes.length > 0 && (
|
||||||
|
<div className='grid grid-cols-5 gap-2'>
|
||||||
|
{episodes.map((episode) => {
|
||||||
|
const isSelected = isEpisodeSelected(episode.episodeId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={episode.episodeId}
|
||||||
|
onClick={() => handleEpisodeSelect(episode)}
|
||||||
|
className={`rounded-lg px-2 py-2 text-xs font-medium transition-colors
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 ' +
|
||||||
|
'dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
title={episode.episodeTitle}
|
||||||
|
>
|
||||||
|
{episode.episodeTitle}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoadingEpisodes && episodes.length === 0 && (
|
||||||
|
<div className='py-8 text-center text-sm text-gray-500'>
|
||||||
|
暂无剧集信息
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 显示搜索结果 */}
|
||||||
|
{!selectedAnime && searchResults.length > 0 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{searchResults.map((anime) => (
|
||||||
|
<div
|
||||||
|
key={anime.animeId}
|
||||||
|
onClick={() => handleAnimeSelect(anime)}
|
||||||
|
className='flex cursor-pointer items-start gap-3 rounded-lg
|
||||||
|
bg-gray-100 p-3 transition-colors hover:bg-gray-200
|
||||||
|
dark:bg-gray-800 dark:hover:bg-gray-700'
|
||||||
|
>
|
||||||
|
{/* 封面 */}
|
||||||
|
{anime.imageUrl && (
|
||||||
|
<div className='h-16 w-12 flex-shrink-0 overflow-hidden rounded'>
|
||||||
|
<img
|
||||||
|
src={anime.imageUrl}
|
||||||
|
alt={anime.animeTitle}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<p className='truncate font-semibold text-gray-800 dark:text-white'>
|
||||||
|
{anime.animeTitle}
|
||||||
|
</p>
|
||||||
|
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400'>
|
||||||
|
<span className='rounded bg-gray-200 px-2 py-0.5 dark:bg-gray-700'>
|
||||||
|
{anime.typeDescription || anime.type}
|
||||||
|
</span>
|
||||||
|
{anime.episodeCount && (
|
||||||
|
<span>{anime.episodeCount} 集</span>
|
||||||
|
)}
|
||||||
|
{anime.startDate && <span>{anime.startDate}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!selectedAnime && searchResults.length === 0 && !isSearching && (
|
||||||
|
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||||
|
<MagnifyingGlassIcon className='mb-3 h-12 w-12 text-gray-400' />
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
输入动漫名称搜索弹幕
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import DanmakuPanel from '@/components/DanmakuPanel';
|
||||||
|
import type { DanmakuSelection } from '@/lib/danmaku/types';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -42,6 +44,9 @@ interface EpisodeSelectorProps {
|
|||||||
sourceSearchError?: string | null;
|
sourceSearchError?: string | null;
|
||||||
/** 预计算的测速结果,避免重复测速 */
|
/** 预计算的测速结果,避免重复测速 */
|
||||||
precomputedVideoInfo?: Map<string, VideoInfo>;
|
precomputedVideoInfo?: Map<string, VideoInfo>;
|
||||||
|
/** 弹幕相关 */
|
||||||
|
onDanmakuSelect?: (selection: DanmakuSelection) => void;
|
||||||
|
currentDanmakuSelection?: DanmakuSelection | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +66,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
sourceSearchLoading = false,
|
sourceSearchLoading = false,
|
||||||
sourceSearchError = null,
|
sourceSearchError = null,
|
||||||
precomputedVideoInfo,
|
precomputedVideoInfo,
|
||||||
|
onDanmakuSelect,
|
||||||
|
currentDanmakuSelection,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||||
@@ -86,11 +93,9 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
videoInfoMapRef.current = videoInfoMap;
|
videoInfoMapRef.current = videoInfoMap;
|
||||||
}, [videoInfoMap]);
|
}, [videoInfoMap]);
|
||||||
|
|
||||||
// 主要的 tab 状态:'episodes' 或 'sources'
|
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
|
||||||
// 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
|
// 默认显示选集选项卡
|
||||||
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
|
const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>('episodes');
|
||||||
totalEpisodes > 1 ? 'episodes' : 'sources'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当前分页索引(0 开始)
|
// 当前分页索引(0 开始)
|
||||||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||||||
@@ -352,6 +357,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||||
{/* 主要的 Tab 切换 - 无缝融入设计 */}
|
{/* 主要的 Tab 切换 - 无缝融入设计 */}
|
||||||
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||||
|
{/* 选集选项卡 - 仅在多集时显示 */}
|
||||||
{totalEpisodes > 1 && (
|
{totalEpisodes > 1 && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setActiveTab('episodes')}
|
onClick={() => setActiveTab('episodes')}
|
||||||
@@ -365,6 +371,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
选集
|
选集
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 换源选项卡 */}
|
||||||
<div
|
<div
|
||||||
onClick={handleSourceTabClick}
|
onClick={handleSourceTabClick}
|
||||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||||
@@ -376,8 +384,31 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
>
|
>
|
||||||
换源
|
换源
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 弹幕选项卡 */}
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveTab('danmaku')}
|
||||||
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||||
|
${activeTab === 'danmaku'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||||
|
}
|
||||||
|
`.trim()}
|
||||||
|
>
|
||||||
|
弹幕
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 弹幕 Tab 内容 */}
|
||||||
|
{activeTab === 'danmaku' && onDanmakuSelect && (
|
||||||
|
<DanmakuPanel
|
||||||
|
videoTitle={videoTitle || ''}
|
||||||
|
currentEpisodeIndex={value - 1}
|
||||||
|
onDanmakuSelect={onDanmakuSelect}
|
||||||
|
currentSelection={currentDanmakuSelection || null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 选集 Tab 内容 */}
|
{/* 选集 Tab 内容 */}
|
||||||
{activeTab === 'episodes' && (
|
{activeTab === 'episodes' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export interface AdminConfig {
|
|||||||
DoubanImageProxy: string;
|
DoubanImageProxy: string;
|
||||||
DisableYellowFilter: boolean;
|
DisableYellowFilter: boolean;
|
||||||
FluidSearch: boolean;
|
FluidSearch: boolean;
|
||||||
|
// 弹幕配置
|
||||||
|
DanmakuApiBase: string;
|
||||||
|
DanmakuApiToken: string;
|
||||||
};
|
};
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: {
|
Users: {
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||||
FluidSearch:
|
FluidSearch:
|
||||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||||
|
// 弹幕配置
|
||||||
|
DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
|
||||||
|
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||||
},
|
},
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: [],
|
Users: [],
|
||||||
@@ -315,6 +318,29 @@ export async function getConfig(): Promise<AdminConfig> {
|
|||||||
|
|
||||||
export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||||
// 确保必要的属性存在和初始化
|
// 确保必要的属性存在和初始化
|
||||||
|
if (!adminConfig.SiteConfig) {
|
||||||
|
adminConfig.SiteConfig = {
|
||||||
|
SiteName: 'MoonTV',
|
||||||
|
Announcement: '',
|
||||||
|
SearchDownstreamMaxPage: 5,
|
||||||
|
SiteInterfaceCacheTime: 7200,
|
||||||
|
DoubanProxyType: 'cmliussss-cdn-tencent',
|
||||||
|
DoubanProxy: '',
|
||||||
|
DoubanImageProxyType: 'cmliussss-cdn-tencent',
|
||||||
|
DoubanImageProxy: '',
|
||||||
|
DisableYellowFilter: false,
|
||||||
|
FluidSearch: true,
|
||||||
|
DanmakuApiBase: 'http://localhost:9321',
|
||||||
|
DanmakuApiToken: '87654321',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 确保弹幕配置存在
|
||||||
|
if (!adminConfig.SiteConfig.DanmakuApiBase) {
|
||||||
|
adminConfig.SiteConfig.DanmakuApiBase = 'http://localhost:9321';
|
||||||
|
}
|
||||||
|
if (!adminConfig.SiteConfig.DanmakuApiToken) {
|
||||||
|
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
||||||
|
}
|
||||||
if (!adminConfig.UserConfig) {
|
if (!adminConfig.UserConfig) {
|
||||||
adminConfig.UserConfig = { Users: [] };
|
adminConfig.UserConfig = { Users: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// 弹幕 API 服务封装(通过本地代理转发)
|
||||||
|
import type {
|
||||||
|
DanmakuAnime,
|
||||||
|
DanmakuComment,
|
||||||
|
DanmakuCommentsResponse,
|
||||||
|
DanmakuEpisodesResponse,
|
||||||
|
DanmakuMatchRequest,
|
||||||
|
DanmakuMatchResponse,
|
||||||
|
DanmakuSearchResponse,
|
||||||
|
DanmakuSettings,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// 搜索动漫
|
||||||
|
export async function searchAnime(
|
||||||
|
keyword: string
|
||||||
|
): Promise<DanmakuSearchResponse> {
|
||||||
|
try {
|
||||||
|
const url = `/api/danmaku/search?keyword=${encodeURIComponent(keyword)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as DanmakuSearchResponse;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索动漫失败:', error);
|
||||||
|
return {
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '搜索失败',
|
||||||
|
animes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动匹配(根据文件名)
|
||||||
|
export async function matchAnime(
|
||||||
|
fileName: string
|
||||||
|
): Promise<DanmakuMatchResponse> {
|
||||||
|
try {
|
||||||
|
const url = '/api/danmaku/match';
|
||||||
|
const requestBody: DanmakuMatchRequest = { fileName };
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as DanmakuMatchResponse;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('自动匹配失败:', error);
|
||||||
|
return {
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '匹配失败',
|
||||||
|
isMatched: false,
|
||||||
|
matches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取剧集列表
|
||||||
|
export async function getEpisodes(
|
||||||
|
animeId: number
|
||||||
|
): Promise<DanmakuEpisodesResponse> {
|
||||||
|
try {
|
||||||
|
const url = `/api/danmaku/episodes?animeId=${animeId}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as DanmakuEpisodesResponse;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取剧集列表失败:', error);
|
||||||
|
return {
|
||||||
|
errorCode: -1,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : '获取失败',
|
||||||
|
bangumi: {
|
||||||
|
bangumiId: '',
|
||||||
|
animeTitle: '',
|
||||||
|
episodes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过剧集 ID 获取弹幕
|
||||||
|
export async function getDanmakuById(
|
||||||
|
episodeId: number
|
||||||
|
): Promise<DanmakuComment[]> {
|
||||||
|
try {
|
||||||
|
const url = `/api/danmaku/comment?episodeId=${episodeId}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as DanmakuCommentsResponse;
|
||||||
|
return data.comments || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取弹幕失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过视频 URL 获取弹幕
|
||||||
|
export async function getDanmakuByUrl(url: string): Promise<DanmakuComment[]> {
|
||||||
|
try {
|
||||||
|
const apiUrl = `/api/danmaku/comment?url=${encodeURIComponent(url)}`;
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as DanmakuCommentsResponse;
|
||||||
|
return data.comments || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取弹幕失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 danmu_api 的弹幕格式转换为 artplayer-plugin-danmuku 格式
|
||||||
|
export function convertDanmakuFormat(
|
||||||
|
comments: DanmakuComment[]
|
||||||
|
): Array<{
|
||||||
|
text: string;
|
||||||
|
time: number;
|
||||||
|
color: string;
|
||||||
|
border: boolean;
|
||||||
|
mode: number;
|
||||||
|
}> {
|
||||||
|
return comments.map((comment) => {
|
||||||
|
// 解析弹幕属性: "时间,类型,字体,颜色,时间戳,弹幕池,用户Hash,弹幕ID"
|
||||||
|
const parts = comment.p.split(',');
|
||||||
|
const time = parseFloat(parts[0]) || 0;
|
||||||
|
const type = parseInt(parts[1]) || 1; // 1=滚动, 4=底部, 5=顶部
|
||||||
|
const colorValue = parseInt(parts[3]) || 16777215; // 默认白色
|
||||||
|
|
||||||
|
// 将十进制颜色值转换为十六进制
|
||||||
|
const color = `#${colorValue.toString(16).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// 转换弹幕类型: 1=滚动(0), 4=底部(1), 5=顶部(2)
|
||||||
|
let mode = 0; // 默认滚动
|
||||||
|
if (type === 5) mode = 1; // 顶部
|
||||||
|
else if (type === 4) mode = 2; // 底部
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: comment.m,
|
||||||
|
time,
|
||||||
|
color,
|
||||||
|
border: false,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认弹幕设置
|
||||||
|
export const DEFAULT_DANMAKU_SETTINGS: DanmakuSettings = {
|
||||||
|
enabled: true,
|
||||||
|
opacity: 1,
|
||||||
|
fontSize: 25,
|
||||||
|
speed: 5,
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 50,
|
||||||
|
maxlength: 100,
|
||||||
|
filterRules: [],
|
||||||
|
unlimited: false,
|
||||||
|
synchronousPlayback: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 localStorage 读取弹幕设置
|
||||||
|
export function loadDanmakuSettings(): DanmakuSettings {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_DANMAKU_SETTINGS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('danmaku_settings');
|
||||||
|
if (saved) {
|
||||||
|
const settings = JSON.parse(saved) as DanmakuSettings;
|
||||||
|
return { ...DEFAULT_DANMAKU_SETTINGS, ...settings };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取弹幕设置失败:', error);
|
||||||
|
}
|
||||||
|
return DEFAULT_DANMAKU_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存弹幕设置到 localStorage
|
||||||
|
export function saveDanmakuSettings(settings: DanmakuSettings): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('danmaku_settings', JSON.stringify(settings));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存弹幕设置失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记忆上次选择的弹幕
|
||||||
|
export interface DanmakuMemory {
|
||||||
|
videoTitle: string;
|
||||||
|
animeId: number;
|
||||||
|
episodeId: number;
|
||||||
|
animeTitle: string;
|
||||||
|
episodeTitle: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存弹幕选择记忆
|
||||||
|
export function saveDanmakuMemory(
|
||||||
|
videoTitle: string,
|
||||||
|
animeId: number,
|
||||||
|
episodeId: number,
|
||||||
|
animeTitle: string,
|
||||||
|
episodeTitle: string
|
||||||
|
): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memory: DanmakuMemory = {
|
||||||
|
videoTitle,
|
||||||
|
animeId,
|
||||||
|
episodeId,
|
||||||
|
animeTitle,
|
||||||
|
episodeTitle,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取现有的记忆
|
||||||
|
const memoriesJson = localStorage.getItem('danmaku_memories');
|
||||||
|
const memories: Record<string, DanmakuMemory> = memoriesJson
|
||||||
|
? JSON.parse(memoriesJson)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// 保存新记忆
|
||||||
|
memories[videoTitle] = memory;
|
||||||
|
|
||||||
|
// 只保留最近 100 条记忆
|
||||||
|
const entries = Object.entries(memories);
|
||||||
|
if (entries.length > 100) {
|
||||||
|
entries.sort((a, b) => b[1].timestamp - a[1].timestamp);
|
||||||
|
const top100 = entries.slice(0, 100);
|
||||||
|
const newMemories = Object.fromEntries(top100);
|
||||||
|
localStorage.setItem('danmaku_memories', JSON.stringify(newMemories));
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('danmaku_memories', JSON.stringify(memories));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存弹幕记忆失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取弹幕选择记忆
|
||||||
|
export function loadDanmakuMemory(
|
||||||
|
videoTitle: string
|
||||||
|
): DanmakuMemory | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memoriesJson = localStorage.getItem('danmaku_memories');
|
||||||
|
if (!memoriesJson) return null;
|
||||||
|
|
||||||
|
const memories: Record<string, DanmakuMemory> = JSON.parse(memoriesJson);
|
||||||
|
return memories[videoTitle] || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取弹幕记忆失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 弹幕 API 类型定义
|
||||||
|
|
||||||
|
// 搜索动漫响应
|
||||||
|
export interface DanmakuSearchResponse {
|
||||||
|
errorCode: number;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
animes: DanmakuAnime[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动漫信息
|
||||||
|
export interface DanmakuAnime {
|
||||||
|
animeId: number;
|
||||||
|
bangumiId?: string;
|
||||||
|
animeTitle: string;
|
||||||
|
type: string;
|
||||||
|
typeDescription: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
startDate?: string;
|
||||||
|
episodeCount?: number;
|
||||||
|
rating?: number;
|
||||||
|
isFavorited?: boolean;
|
||||||
|
source: string;
|
||||||
|
links?: DanmakuLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放链接
|
||||||
|
export interface DanmakuLink {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取弹幕响应
|
||||||
|
export interface DanmakuCommentsResponse {
|
||||||
|
count: number;
|
||||||
|
comments: DanmakuComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕数据
|
||||||
|
export interface DanmakuComment {
|
||||||
|
p: string; // 弹幕属性: "时间,类型,字体,颜色,时间戳,弹幕池,用户Hash,弹幕ID"
|
||||||
|
m: string; // 弹幕内容
|
||||||
|
cid: number; // 弹幕ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕设置
|
||||||
|
export interface DanmakuSettings {
|
||||||
|
enabled: boolean; // 是否开启弹幕
|
||||||
|
opacity: number; // 不透明度 (0-1)
|
||||||
|
fontSize: number; // 字体大小
|
||||||
|
speed: number; // 弹幕速度 (5-20)
|
||||||
|
marginTop: number; // 顶部边距
|
||||||
|
marginBottom: number; // 底部边距
|
||||||
|
maxlength: number; // 最大弹幕数
|
||||||
|
filterRules: string[]; // 过滤规则(正则表达式)
|
||||||
|
unlimited: boolean; // 无限弹幕
|
||||||
|
synchronousPlayback: boolean; // 同步播放
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动匹配请求
|
||||||
|
export interface DanmakuMatchRequest {
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动匹配响应
|
||||||
|
export interface DanmakuMatchResponse {
|
||||||
|
errorCode: number;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
isMatched: boolean;
|
||||||
|
matches: DanmakuMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配结果
|
||||||
|
export interface DanmakuMatch {
|
||||||
|
episodeId: number;
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string;
|
||||||
|
episodeTitle: string;
|
||||||
|
type: string;
|
||||||
|
typeDescription: string;
|
||||||
|
shift: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剧集列表响应
|
||||||
|
export interface DanmakuEpisodesResponse {
|
||||||
|
errorCode: number;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
bangumi: DanmakuBangumi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 番剧信息
|
||||||
|
export interface DanmakuBangumi {
|
||||||
|
bangumiId: string;
|
||||||
|
animeTitle: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
episodes: DanmakuEpisode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剧集信息
|
||||||
|
export interface DanmakuEpisode {
|
||||||
|
episodeId: number;
|
||||||
|
episodeTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕选择状态
|
||||||
|
export interface DanmakuSelection {
|
||||||
|
animeId: number;
|
||||||
|
episodeId: number;
|
||||||
|
animeTitle: string;
|
||||||
|
episodeTitle: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user