diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index b8dfd2f..47e13a1 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,6 +1,10 @@
{
"permissions": {
- "allow": ["WebSearch", "Bash(curl:*)"],
+ "allow": [
+ "WebSearch",
+ "Bash(curl:*)",
+ "Bash(find:*)"
+ ],
"deny": [],
"ask": []
}
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..72f28e0
--- /dev/null
+++ b/.env.example
@@ -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)
diff --git a/DANMAKU_INTEGRATION.md b/DANMAKU_INTEGRATION.md
new file mode 100644
index 0000000..1c3eff7
--- /dev/null
+++ b/DANMAKU_INTEGRATION.md
@@ -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
diff --git a/DANMAKU_PROXY_IMPLEMENTATION.md b/DANMAKU_PROXY_IMPLEMENTATION.md
new file mode 100644
index 0000000..b5eb9bc
--- /dev/null
+++ b/DANMAKU_PROXY_IMPLEMENTATION.md
@@ -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
+**状态**: ✅ 已完成并测试通过
diff --git a/package.json b/package.json
index 0ac8e8e..9390913 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@vidstack/react": "^1.12.13",
"anime4k-webgpu": "^1.0.0",
"artplayer": "^5.2.5",
+ "artplayer-plugin-danmuku": "^5.2.0",
"bs58": "^6.0.0",
"clsx": "^2.0.0",
"crypto-js": "^4.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f308a49..30a6b9f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
artplayer:
specifier: ^5.2.5
version: 5.2.5
+ artplayer-plugin-danmuku:
+ specifier: ^5.2.0
+ version: 5.2.0
bs58:
specifier: ^6.0.0
version: 6.0.0
@@ -1155,24 +1158,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.30':
resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@next/swc-linux-x64-gnu@14.2.30':
resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-x64-musl@14.2.30':
resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.30':
resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==}
@@ -1689,41 +1696,49 @@ packages:
resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.9.0':
resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.9.0':
resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.9.0':
resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.9.0':
resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.9.0':
resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.9.0':
resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.9.0':
resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.9.0':
resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==}
@@ -1998,6 +2013,9 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'}
+ artplayer-plugin-danmuku@5.2.0:
+ resolution: {integrity: sha512-iHHSGEc0J+gl7imX4nmLqqC7LXmv8xeGf2lUevhhyUCoiCRZplecpKQICHjErVb1Yqyp1xCy6Sp8eYN+EpFWFw==}
+
artplayer@5.2.5:
resolution: {integrity: sha512-Ogym5rvkAJ4VLncM4Apl3TJ/a/ozM3csvY4IKuuMR++hUmEZgj/HaGsNonwx8r56nsqiZYE7O4vS1HFZl+NBSg==}
@@ -7496,6 +7514,8 @@ snapshots:
arrify@2.0.1: {}
+ artplayer-plugin-danmuku@5.2.0: {}
+
artplayer@5.2.5:
dependencies:
option-validator: 2.0.6
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 3c5cce0..9780be5 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -267,6 +267,8 @@ interface SiteConfig {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
+ DanmakuApiBase: string;
+ DanmakuApiToken: string;
}
// 视频源数据类型
@@ -3393,6 +3395,8 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
+ DanmakuApiBase: 'http://localhost:9321',
+ DanmakuApiToken: '87654321',
});
// 豆瓣数据源相关状态
@@ -3455,6 +3459,8 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
FluidSearch: config.SiteConfig.FluidSearch || true,
+ DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
+ DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
});
}
}, [config]);
@@ -3903,10 +3909,60 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
- 启用后搜索结果将实时流式返回,提升用户体验。
+ 启用后搜索结果将实时流式返回,提升用户体验。
+ {/* 弹幕 API 配置 */}
+
+
+ 弹幕配置
+
+
+ {/* 弹幕 API 地址 */}
+
+
+
+ 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'
+ />
+
+ 弹幕服务器的 API 地址,默认为 http://localhost:9321
+
+
+
+ {/* 弹幕 API Token */}
+
+
+
+ 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'
+ />
+
+ 弹幕服务器的访问令牌,默认为 87654321
+
+
+
{/* 操作按钮 */}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts
index b06539a..42c8cf3 100644
--- a/src/app/api/admin/site/route.ts
+++ b/src/app/api/admin/site/route.ts
@@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
+ DanmakuApiBase,
+ DanmakuApiToken,
} = body as {
SiteName: string;
Announcement: string;
@@ -50,6 +52,8 @@ export async function POST(request: NextRequest) {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
+ DanmakuApiBase: string;
+ DanmakuApiToken: string;
};
// 参数校验
@@ -63,7 +67,9 @@ export async function POST(request: NextRequest) {
typeof DoubanImageProxyType !== 'string' ||
typeof DoubanImageProxy !== 'string' ||
typeof DisableYellowFilter !== 'boolean' ||
- typeof FluidSearch !== 'boolean'
+ typeof FluidSearch !== 'boolean' ||
+ typeof DanmakuApiBase !== 'string' ||
+ typeof DanmakuApiToken !== 'string'
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -93,6 +99,8 @@ export async function POST(request: NextRequest) {
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
+ DanmakuApiBase,
+ DanmakuApiToken,
};
// 写入数据库
diff --git a/src/app/api/danmaku/comment/route.ts b/src/app/api/danmaku/comment/route.ts
new file mode 100644
index 0000000..480a907
--- /dev/null
+++ b/src/app/api/danmaku/comment/route.ts
@@ -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 }> = [];
+
+ // 使用正则表达式提取所有
标签
+ const dTagRegex = /]*>([^<]*)<\/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 }
+ );
+ }
+}
diff --git a/src/app/api/danmaku/episodes/route.ts b/src/app/api/danmaku/episodes/route.ts
new file mode 100644
index 0000000..488be41
--- /dev/null
+++ b/src/app/api/danmaku/episodes/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/danmaku/match/route.ts b/src/app/api/danmaku/match/route.ts
new file mode 100644
index 0000000..d678524
--- /dev/null
+++ b/src/app/api/danmaku/match/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/danmaku/search/route.ts b/src/app/api/danmaku/search/route.ts
new file mode 100644
index 0000000..4924fef
--- /dev/null
+++ b/src/app/api/danmaku/search/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 3fcdbb5..360ca7b 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -3,6 +3,7 @@
'use client';
import Artplayer from 'artplayer';
+import artplayerPluginDanmuku from 'artplayer-plugin-danmuku';
import Hls from 'hls.js';
import { Heart } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -23,6 +24,17 @@ import {
saveSkipConfig,
subscribeToDataUpdates,
} 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 { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
@@ -153,6 +165,23 @@ function PlayPageClient() {
checkWebGPUSupport();
}, []);
+ // 弹幕相关状态
+ const [danmakuSettings, setDanmakuSettings] = useState(
+ loadDanmakuSettings()
+ );
+ const [currentDanmakuSelection, setCurrentDanmakuSelection] =
+ useState(null);
+ const [danmakuEpisodesList, setDanmakuEpisodesList] = useState<
+ Array<{ episodeId: number; episodeTitle: string }>
+ >([]);
+ const [danmakuLoading, setDanmakuLoading] = useState(false);
+ const danmakuPluginRef = useRef(null);
+ const danmakuSettingsRef = useRef(danmakuSettings);
+
+ useEffect(() => {
+ danmakuSettingsRef.current = danmakuSettings;
+ }, [danmakuSettings]);
+
// 视频基本信息
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
@@ -203,6 +232,31 @@ function PlayPageClient() {
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('');
@@ -1091,24 +1145,10 @@ function PlayPageClient() {
setLoadingStage('ready');
setLoadingMessage('✨ 准备就绪,即将开始播放...');
- // 短暂延迟让用户看到完成状态
- setTimeout(() => {
- setLoading(false);
- }, 1000);
- };
-
- initAll();
- }, []);
-
- // 播放记录处理
- useEffect(() => {
- // 仅在初次挂载时检查播放记录
- const initFromHistory = async () => {
- if (!currentSource || !currentId) return;
-
+ // 加载播放记录
try {
const allRecords = await getAllPlayRecords();
- const key = generateStorageKey(currentSource, currentId);
+ const key = generateStorageKey(detailData.source, detailData.id);
const record = allRecords[key];
if (record) {
@@ -1116,8 +1156,9 @@ function PlayPageClient() {
const targetTime = record.play_time;
// 更新当前选集索引
- if (targetIndex !== currentEpisodeIndex) {
+ if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
setCurrentEpisodeIndex(targetIndex);
+ currentEpisodeIndexRef.current = targetIndex;
}
// 保存待恢复的播放进度,待播放器就绪后跳转
@@ -1126,9 +1167,14 @@ function PlayPageClient() {
} catch (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: {
loading:
'
',
@@ -1707,6 +1951,115 @@ function PlayPageClient() {
return newVal ? '当前开启' : '当前关闭';
},
},
+ // 弹幕开关
+ {
+ name: '弹幕开关',
+ html: '弹幕开关',
+ icon: '',
+ 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 ? [
{
name: 'Anime4K超分',
@@ -1877,6 +2230,21 @@ function PlayPageClient() {
artPlayerRef.current.on('ready', async () => {
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
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
@@ -2334,6 +2702,16 @@ function PlayPageClient() {
)}
+
+ {/* 弹幕加载蒙层 */}
+ {danmakuLoading && (
+
+ )}
{/* 第三方应用打开按钮 */}
@@ -2547,6 +2925,8 @@ function PlayPageClient() {
sourceSearchLoading={sourceSearchLoading}
sourceSearchError={sourceSearchError}
precomputedVideoInfo={precomputedVideoInfo}
+ onDanmakuSelect={handleDanmakuSelect}
+ currentDanmakuSelection={currentDanmakuSelection}
/>
diff --git a/src/components/DanmakuPanel.tsx b/src/components/DanmakuPanel.tsx
new file mode 100644
index 0000000..f69f8c8
--- /dev/null
+++ b/src/components/DanmakuPanel.tsx
@@ -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([]);
+ const [selectedAnime, setSelectedAnime] = useState(null);
+ const [episodes, setEpisodes] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
+ const [searchError, setSearchError] = useState(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 (
+
+ {/* 搜索区域 */}
+
+
+ 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}
+ />
+
+
+
+ {/* 当前选择的弹幕信息 */}
+ {currentSelection && (
+
+
+ 当前弹幕
+
+
+ {currentSelection.animeTitle}
+
+
+ {currentSelection.episodeTitle}
+
+
+ )}
+
+ {/* 错误提示 */}
+ {searchError && (
+
+ {searchError}
+
+ )}
+
+
+ {/* 内容区域 */}
+
+ {/* 显示剧集列表 */}
+ {selectedAnime && (
+
+ {/* 返回按钮 */}
+
+
+ {/* 动漫标题 */}
+
+ {selectedAnime.animeTitle}
+
+
+ {/* 加载中 */}
+ {isLoadingEpisodes && (
+
+ )}
+
+ {/* 剧集网格 */}
+ {!isLoadingEpisodes && episodes.length > 0 && (
+
+ {episodes.map((episode) => {
+ const isSelected = isEpisodeSelected(episode.episodeId);
+ return (
+
+ );
+ })}
+
+ )}
+
+ {!isLoadingEpisodes && episodes.length === 0 && (
+
+ 暂无剧集信息
+
+ )}
+
+ )}
+
+ {/* 显示搜索结果 */}
+ {!selectedAnime && searchResults.length > 0 && (
+
+ {searchResults.map((anime) => (
+
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 && (
+
+

{
+ e.currentTarget.style.display = 'none';
+ }}
+ />
+
+ )}
+
+ {/* 信息 */}
+
+
+ {anime.animeTitle}
+
+
+
+ {anime.typeDescription || anime.type}
+
+ {anime.episodeCount && (
+ {anime.episodeCount} 集
+ )}
+ {anime.startDate && {anime.startDate}}
+
+
+
+ ))}
+
+ )}
+
+ {/* 空状态 */}
+ {!selectedAnime && searchResults.length === 0 && !isSearching && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx
index 93a124c..38e3c86 100644
--- a/src/components/EpisodeSelector.tsx
+++ b/src/components/EpisodeSelector.tsx
@@ -9,6 +9,8 @@ import React, {
useState,
} from 'react';
+import DanmakuPanel from '@/components/DanmakuPanel';
+import type { DanmakuSelection } from '@/lib/danmaku/types';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
@@ -42,6 +44,9 @@ interface EpisodeSelectorProps {
sourceSearchError?: string | null;
/** 预计算的测速结果,避免重复测速 */
precomputedVideoInfo?: Map;
+ /** 弹幕相关 */
+ onDanmakuSelect?: (selection: DanmakuSelection) => void;
+ currentDanmakuSelection?: DanmakuSelection | null;
}
/**
@@ -61,6 +66,8 @@ const EpisodeSelector: React.FC = ({
sourceSearchLoading = false,
sourceSearchError = null,
precomputedVideoInfo,
+ onDanmakuSelect,
+ currentDanmakuSelection,
}) => {
const router = useRouter();
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
@@ -86,11 +93,9 @@ const EpisodeSelector: React.FC = ({
videoInfoMapRef.current = videoInfoMap;
}, [videoInfoMap]);
- // 主要的 tab 状态:'episodes' 或 'sources'
- // 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
- const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
- totalEpisodes > 1 ? 'episodes' : 'sources'
- );
+ // 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
+ // 默认显示选集选项卡
+ const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>('episodes');
// 当前分页索引(0 开始)
const initialPage = Math.floor((value - 1) / episodesPerPage);
@@ -352,6 +357,7 @@ const EpisodeSelector: React.FC = ({
{/* 主要的 Tab 切换 - 无缝融入设计 */}
+ {/* 选集选项卡 - 仅在多集时显示 */}
{totalEpisodes > 1 && (
setActiveTab('episodes')}
@@ -365,6 +371,8 @@ const EpisodeSelector: React.FC = ({
选集
)}
+
+ {/* 换源选项卡 */}
= ({
>
换源
+
+ {/* 弹幕选项卡 */}
+
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()}
+ >
+ 弹幕
+
+ {/* 弹幕 Tab 内容 */}
+ {activeTab === 'danmaku' && onDanmakuSelect && (
+
+ )}
+
{/* 选集 Tab 内容 */}
{activeTab === 'episodes' && (
<>
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index e29fa00..719d5e2 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -16,6 +16,9 @@ export interface AdminConfig {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
+ // 弹幕配置
+ DanmakuApiBase: string;
+ DanmakuApiToken: string;
};
UserConfig: {
Users: {
diff --git a/src/lib/config.ts b/src/lib/config.ts
index d984c63..07bdb79 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -218,6 +218,9 @@ async function getInitConfig(configFile: string, subConfig: {
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
FluidSearch:
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
+ // 弹幕配置
+ DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
+ DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
},
UserConfig: {
Users: [],
@@ -315,6 +318,29 @@ export async function getConfig(): Promise
{
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) {
adminConfig.UserConfig = { Users: [] };
}
diff --git a/src/lib/danmaku/api.ts b/src/lib/danmaku/api.ts
new file mode 100644
index 0000000..dfbbc8e
--- /dev/null
+++ b/src/lib/danmaku/api.ts
@@ -0,0 +1,285 @@
+// 弹幕 API 服务封装(通过本地代理转发)
+import type {
+ DanmakuAnime,
+ DanmakuComment,
+ DanmakuCommentsResponse,
+ DanmakuEpisodesResponse,
+ DanmakuMatchRequest,
+ DanmakuMatchResponse,
+ DanmakuSearchResponse,
+ DanmakuSettings,
+} from './types';
+
+// 搜索动漫
+export async function searchAnime(
+ keyword: string
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = 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 = JSON.parse(memoriesJson);
+ return memories[videoTitle] || null;
+ } catch (error) {
+ console.error('读取弹幕记忆失败:', error);
+ return null;
+ }
+}
diff --git a/src/lib/danmaku/types.ts b/src/lib/danmaku/types.ts
new file mode 100644
index 0000000..d53ca27
--- /dev/null
+++ b/src/lib/danmaku/types.ts
@@ -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;
+}