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 && ( +
+ {anime.animeTitle} { + 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; +}