diff --git a/README.md b/README.md index 9b490a1..5d691a3 100644 --- a/README.md +++ b/README.md @@ -38,20 +38,47 @@ - **自动连播**:支持自动播放下一集 - **移动端优化**:专门为移动设备优化的播放器界面和双击手势控制 - **Chromecast 投屏**:支持通过 Google Cast 投屏到电视等设备 +- **画中画 (PiP)**:支持浮动窗口模式播放 - **全屏模式选择**:支持系统全屏和网页全屏两种模式 - **代理播放**:三种代理模式(智能重试、仅直连、总是代理),灵活应对不同网络环境 - **卡顿检测**:自动检测播放卡顿并提示 +- **键盘快捷键**:空格/K 播放暂停、F 全屏、M 静音、P 画中画、J/L/方向键 快进快退、上下键 音量调节 ### 多源并行搜索 -- **聚合搜索引擎**:同时在多个视频源中并行搜索,大幅提升搜索速度 +- **聚合搜索引擎**:同时在多个视频源中并行搜索,通过 Server-Sent Events (SSE) 实时返回结果 - **自定义视频源**:支持添加、编辑和管理自定义视频源 +- **个人视频源**:非管理员用户可添加个人视频源,不影响其他用户(数据按用户隔离) - **订阅源管理**:支持通过 JSON 链接批量导入和自动更新视频源 +- **JSON 批量导入**:在导入设置中直接粘贴 JSON 数组格式的视频源进行批量添加 - **智能解析**:统一的解析器系统,自动处理不同源的数据格式 - **搜索历史**:自动保存搜索历史,支持快速重新搜索 - **搜索结果显示**:支持默认显示和合并同名源两种模式 - **实时延迟监测**:可选实时显示各源的网络延迟 -- **源过滤**:支持按源和类型筛选搜索结果 +- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示 +- **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签 + +### 多线路折叠 + +- **智能折叠**:播放页面的线路列表默认只显示前 5 个源,避免长列表影响体验 +- **展开/收起**:超过 5 个源时显示"展开更多 (N)"按钮,可随时展开查看全部线路 +- **按类型分组**:当线路包含类型信息时,自动按内容类型分组显示(如"电影"、"电视剧"等) +- **延迟排序**:线路按网络延迟自动排序,最快的源排在前面 +- **源切换**:在线路列表中快速切换到其他源,支持断点续播 + +### IPTV 直播 + +- **M3U 播放列表**:支持导入和管理 M3U/M3U8 格式的 IPTV 源 +- **频道网格**:按分组展示频道,支持分页浏览 +- **自定义请求头**:自动解析 M3U 中的 `http-user-agent` 和 `http-referrer` 属性,通过代理传递 +- **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写 +- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式 +- **重定向跟随**:自动跟随 HTTP 3xx 重定向,提升兼容性 +- **超时保护**:15 秒请求超时、30 秒加载超时、20 秒分片加载超时、3 次清单重试 +- **逐源频道缓存**:每个 IPTV 源的频道独立缓存,避免重复加载 +- **并发控制**:最多同时拉取 3 个源,防止网络拥堵 +- **权限控制**:通过 `iptv_access` 权限控制谁可以访问 IPTV 功能 +- **键盘快捷键**:播放器内支持空格暂停/继续、F 全屏、M 静音、方向键调节音量等 ### 豆瓣集成 @@ -74,6 +101,7 @@ - **一键收藏**:在搜索结果和播放页面快速收藏视频 - **收藏列表**:独立的收藏侧边栏,快速访问已收藏的视频 +- **容量限制**:每个模式最多 100 条收藏 - **普通/高级隔离**:普通模式和高级模式的收藏互相独立 - **数据隔离**:多账户场景下,每个用户拥有独立的收藏数据 @@ -81,14 +109,35 @@ - **自动记录**:自动记录观看进度和历史 - **断点续播**:从上次观看位置继续播放 +- **智能去重**:按标题去重(V2),相同标题不同源的历史合并为一条 +- **容量限制**:最多保存 50 条历史记录 - **历史管理**:支持删除单条历史或清空全部历史 - **隐私保护**:所有数据存储在本地,不上传到服务器 +### 弹幕 (Danmaku) + +- **弹幕聚合**:接入自建弹幕聚合 API(兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式),自动匹配当前播放内容 +- **Canvas 渲染**:基于 Canvas 的高性能弹幕渲染,支持数百条弹幕同时滚动,不影响播放交互 +- **滚动/顶部/底部弹幕**:支持三种弹幕类型,自动分配轨道防止重叠 +- **自定义显示**:可调节弹幕透明度(10%-100%)和字号(14/18/20/24/28px) +- **显示区域**:可选 25%/50%/75%/100% 的屏幕区域显示弹幕 +- **播放器联动**:暂停时弹幕冻结,跳转时自动清除,全屏模式下正常显示 +- **多 API 管理**:每个用户可添加多个弹幕 API,选择当前使用的 API +- **优先级规则**:用户选择的弹幕 API 优先于系统默认配置 +- **环境变量预设**:可通过 `NEXT_PUBLIC_DANMAKU_API_URL` 为所有用户预设弹幕 API 地址 + +### 广告过滤 + +- **多模式选择**:支持关闭、关键词过滤、智能启发式过滤(Beta)和激进模式 +- **UI 集成**:在播放器设置菜单中直接切换模式,实时生效 +- **自定义关键词**:支持通过环境变量或文件扩展过滤关键词 +- **高性能**:基于流式处理,对播放加载速度几乎无影响 + ### 响应式设计 - **全端适配**:完美支持桌面、平板、移动设备和 TV/机顶盒 - **移动优先**:专门的移动端组件和交互设计 -- **触摸优化**:针对触摸屏优化的手势和交互 +- **触摸优化**:针对触摸屏优化的手势和交互(双击快进/快退、滑动音量控制) - **TV 适配**:遥控器方向键导航和大屏 UI 优化 ### 主题系统 @@ -115,6 +164,8 @@ - **内容隔离**:高级内容与普通内容完全物理隔离,互不干扰 - **专属设置**:拥有独立的内容源管理和功能设置(播放器、显示、弹幕等设置完全独立) - **独立推荐**:基于高级模式观看历史的个性化推荐,与普通模式互不影响 +- **分类浏览**:支持模糊匹配合并多源的相同分类标签,提供统一浏览体验 +- **交错排列**:多源结果智能交错排列,平衡各源展示 ### TV/大屏适配 @@ -139,27 +190,13 @@ - **TV 模式注入**:页面加载后自动注入 `tv-mode` CSS 类,激活大屏优化样式 - **可配置 URL**:在 `ContentView.swift` 中修改 `kvideoURL` 常量指向你的部署实例 -### 广告过滤 - -- **多模式选择**:支持关闭、关键词过滤、智能启发式过滤(Beta)和激进模式 -- **UI 集成**:在播放器设置菜单中直接切换模式,实时生效 -- **自定义关键词**:支持通过环境变量或文件扩展过滤关键词 -- **高性能**:基于流式处理,对播放加载速度几乎无影响 - -### 弹幕 (Danmaku) - -- **弹幕聚合**:接入自建弹幕聚合 API(兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式),自动匹配当前播放内容 -- **Canvas 渲染**:基于 Canvas 的高性能弹幕渲染,支持数百条弹幕同时滚动,不影响播放交互 -- **滚动/顶部/底部弹幕**:支持三种弹幕类型,自动分配轨道防止重叠 -- **自定义显示**:可调节弹幕透明度(10%-100%)和字号(14/18/20/24/28px) -- **播放器联动**:暂停时弹幕冻结,跳转时自动清除,全屏模式下正常显示 -- **环境变量预设**:可通过 `NEXT_PUBLIC_DANMAKU_API_URL` 为所有用户预设弹幕 API 地址 - ### 数据管理 - **设置导出/导入**:支持将所有设置导出为 JSON 文件,方便备份和迁移 +- **JSON 批量导入**:支持粘贴 JSON 数组格式的视频源进行批量导入 - **滚动位置记忆**:退出或刷新页面后,自动恢复到之前的滚动位置 - **返回顶部**:一键返回页面顶部 +- **数据隔离**:多账户场景下,所有用户数据(历史、收藏、设置、个人源)按 profileId 完全隔离 ## 隐私保护 @@ -168,11 +205,12 @@ - **本地存储**:所有数据存储在本地浏览器中 - **无服务器数据**:不收集或上传任何用户数据 - **自定义源**:用户可自行配置视频源 -- **数据隔离**:多账户场景下,每个用户的数据(历史、收藏、设置)完全隔离 +- **数据隔离**:多账户场景下,每个用户的数据(历史、收藏、设置、个人源)完全隔离 +- **Profile ID**:基于密码的 SHA-256 哈希生成唯一用户标识,密码不可逆推 ## 账户与访问控制 -KVideo 支持基于环境变量的账户认证系统,支持角色区分(管理员/观众)和数据隔离。 +KVideo 支持基于环境变量的账户认证系统,支持角色区分和细粒度权限控制。 ### 方式一:单管理员密码 @@ -183,27 +221,59 @@ KVideo 支持基于环境变量的账户认证系统,支持角色区分(管 docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhaoyang/kvideo:latest ``` -登录后自动获得管理员权限,可管理所有设置。 +登录后自动获得超级管理员权限,可管理所有设置。 > **向后兼容**:`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。 ### 方式二:多账户系统 -通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置等)。 +通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。 -**格式:** `密码:名称[:角色]`,多个账户用逗号分隔。角色可选 `admin`(管理员)或 `viewer`(观众,默认)。 +**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。 + +- **角色**:`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认) +- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限 ```bash -# Docker +# 基本用法 docker run -d -p 3000:3000 \ -e ACCOUNTS="pass1:张三:admin,pass2:李四:viewer,pass3:王五" \ --name kvideo kuekhaoyang/kvideo:latest + +# 为观众添加额外权限(如 IPTV 访问和源管理) +docker run -d -p 3000:3000 \ + -e ACCOUNTS="pass1:张三:admin,pass2:李四:viewer:iptv_access|source_management" \ + --name kvideo kuekhaoyang/kvideo:latest ``` **特点:** -- 每个账户拥有独立的收藏、历史和设置数据 -- 管理员可管理源和设置,观众仅可查看和播放 +- 每个账户拥有独立的收藏、历史、设置和个人视频源数据 - 可同时配置 `ADMIN_PASSWORD`(作为超级管理员入口) +- 支持为任何角色添加额外的自定义权限 + +### 角色与权限 + +| 权限 | 说明 | super_admin | admin | viewer | +|------|------|:-----------:|:-----:|:------:| +| `source_management` | 管理系统视频源 | ✓ | - | - | +| `account_management` | 查看账户列表 | ✓ | - | - | +| `danmaku_api` | 配置系统弹幕 API | ✓ | - | - | +| `data_management` | 导出/导入/重置数据 | ✓ | - | - | +| `player_settings` | 播放器设置 | ✓ | ✓ | - | +| `danmaku_appearance` | 弹幕外观设置 | ✓ | ✓ | - | +| `view_settings` | 显示设置 | ✓ | ✓ | ✓ | +| `iptv_access` | 访问 IPTV 功能 | ✓ | ✓ | - | + +> 通过 ACCOUNTS 的第 4 个字段,可以为任何角色添加上表中的额外权限。例如让观众也能访问 IPTV:`password:name:viewer:iptv_access` + +### 个人视频源与弹幕 API + +所有已登录的用户(包括观众)都可以: + +- **添加个人视频源**:在设置页面添加自己的视频源,仅对自己可见,不影响其他用户 +- **管理弹幕 API**:添加多个弹幕 API 端点,选择当前使用的 API(优先于系统默认) + +这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。 ### 方式三:会话持久化设置 @@ -337,6 +407,8 @@ docker run -d -p 3000:3000 \ 设置后用户在播放器菜单中即可直接开启弹幕,也可在设置页面中覆盖此地址。 +> 用户还可以在设置页面的「弹幕 API」区域添加多个 API 端点并选择当前使用的,用户选择的 API 优先于系统默认配置。 + ## 自定义源 JSON 格式 如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。 @@ -349,12 +421,15 @@ docker run -d -p 3000:3000 \ | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `id` | string | 是 | 唯一标识符,建议使用英文 | +| `id` | string | 否 | 唯一标识符,自动生成或自定义 | | `name` | string | 是 | 显示名称 | | `baseUrl` | string | 是 | API 地址 (例如: `https://example.com/api.php/provide/vod`) | +| `searchPath` | string | 否 | 搜索路径,默认 `/provide/vod` | +| `detailPath` | string | 否 | 详情路径,默认 `/provide/vod` | | `group` | string | 否 | 分组,可选值: `"normal"` (默认) 或 `"premium"` | | `enabled` | boolean | 否 | 是否启用,默认为 `true` | | `priority` | number | 否 | 优先级,数字越小优先级越高,默认为 1 | +| `headers` | object | 否 | 自定义请求头 | **示例 JSON:** @@ -377,6 +452,10 @@ docker run -d -p 3000:3000 \ ] ``` +### JSON 批量导入 + +除了通过订阅 URL 或文件导入外,还可以在导入设置中选择 **JSON** 标签页,直接粘贴上述格式的 JSON 数组进行批量导入。系统会解析、预览数量后再确认导入。 + ### 重要的区别说明:订阅源 vs 视频源 **这是一个常见的误区,请仔细阅读:** @@ -402,7 +481,7 @@ docker run -d -p 3000:3000 \ |--------|------|--------| | `ADMIN_PASSWORD` | 管理员密码 | - | | `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - | -| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色]`,逗号分隔 | - | +| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - | | `PERSIST_SESSION` | 是否持久化登录会话 | `true` | | `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` | | `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` | @@ -438,11 +517,13 @@ docker run -d -p 3000:3000 \ ### 架构特点 - **App Router**:Next.js 的新路由系统,支持服务端组件和流式渲染 -- **API Routes**:内置 API 端点,处理认证、豆瓣数据和视频源代理 +- **API Routes**:内置 API 端点,处理认证、豆瓣数据、视频源代理、IPTV 代理和弹幕聚合 +- **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果 - **Service Worker**:PWA 支持和资源缓存 - **Server Components**:优化首屏加载性能 - **Client Components**:复杂交互和状态管理 - **Edge Runtime**:API 路由运行在 Edge Runtime 上,提供更低延迟 +- **Profile-based Storage**:所有用户数据按 profileId(SHA-256 哈希)隔离存储 ## 快速部署 @@ -518,6 +599,18 @@ docker run -d -p 3000:3000 --name kvideo kvideo docker-compose up -d ``` +**完整配置示例(Docker):** + +```bash +docker run -d -p 3000:3000 \ + -e ADMIN_PASSWORD="admin123" \ + -e ACCOUNTS="user1:用户一:admin,user2:用户二:viewer:iptv_access" \ + -e NEXT_PUBLIC_SITE_NAME="我的视频" \ + -e NEXT_PUBLIC_DANMAKU_API_URL="https://danmaku.example.com" \ + -e SUBSCRIPTION_SOURCES='[{"name":"默认源","url":"https://example.com/sources.json"}]' \ + --name kvideo kuekhaoyang/kvideo:latest +``` + #### 选项 4:传统 Node.js 部署 ```bash diff --git a/app/api/auth/accounts/route.ts b/app/api/auth/accounts/route.ts index b18599d..d7fcb9d 100644 --- a/app/api/auth/accounts/route.ts +++ b/app/api/auth/accounts/route.ts @@ -16,6 +16,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; interface AccountInfo { name: string; role: 'super_admin' | 'admin' | 'viewer'; + customPermissions?: string[]; } function getAccountList(): AccountInfo[] { @@ -37,8 +38,12 @@ function getAccountList(): AccountInfo[] { const name = parts[1].trim(); const parsedRole = parts[2]?.trim(); const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer'; + const perms = parts[3]?.trim(); + const customPermissions = perms + ? perms.split('|').map(p => p.trim()).filter(p => p.length > 0) + : undefined; if (name) { - accounts.push({ name, role }); + accounts.push({ name, role, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) }); } } }); diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 8220e43..c031124 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -20,6 +20,7 @@ interface AccountEntry { password: string; name: string; role: 'super_admin' | 'admin' | 'viewer'; + customPermissions: string[]; } function parseAccounts(): AccountEntry[] { @@ -31,12 +32,16 @@ function parseAccounts(): AccountEntry[] { .map(entry => { const parts = entry.split(':'); if (parts.length < 2) return null; - const [password, name, role] = parts; + const [password, name, role, perms] = parts; const parsedRole = role?.trim(); + const customPermissions = perms + ? perms.split('|').map(p => p.trim()).filter(p => p.length > 0) + : []; return { password: password.trim(), name: name.trim(), role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer', + customPermissions, }; }) .filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0); @@ -96,6 +101,7 @@ export async function POST(request: NextRequest) { role: account.role, profileId, persistSession: PERSIST_SESSION, + customPermissions: account.customPermissions.length > 0 ? account.customPermissions : undefined, }); } } diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts index 23e7fbd..f9446ec 100644 --- a/app/api/iptv/stream/route.ts +++ b/app/api/iptv/stream/route.ts @@ -46,6 +46,8 @@ function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): strin export async function GET(request: NextRequest) { const url = request.nextUrl.searchParams.get('url'); + const customUa = request.nextUrl.searchParams.get('ua'); + const customReferer = request.nextUrl.searchParams.get('referer'); if (!url) { return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); @@ -54,9 +56,9 @@ export async function GET(request: NextRequest) { try { const parsedUrl = new URL(url); const fetchHeaders: Record = { - 'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)', + 'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)', 'Accept': '*/*', - 'Referer': `${parsedUrl.protocol}//${parsedUrl.host}/`, + 'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`, 'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`, }; @@ -66,7 +68,15 @@ export async function GET(request: NextRequest) { fetchHeaders['Range'] = rangeHeader; } - const response = await fetch(url, { headers: fetchHeaders }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(url, { + headers: fetchHeaders, + redirect: 'follow', + signal: controller.signal, + }); + clearTimeout(timeout); if (!response.ok) { return NextResponse.json( @@ -76,10 +86,44 @@ export async function GET(request: NextRequest) { } const contentType = response.headers.get('content-type') || ''; - const isM3u8 = url.includes('.m3u8') || + let isM3u8 = url.includes('.m3u8') || contentType.includes('mpegurl') || contentType.includes('x-mpegURL'); + // If content-type is ambiguous, check the response body for M3U header + if (!isM3u8 && (contentType.includes('text/plain') || contentType.includes('application/octet-stream') || !contentType)) { + const text = await response.text(); + if (text.trimStart().startsWith('#EXTM3U') || text.trimStart().startsWith('#EXT-X-')) { + isM3u8 = true; + } + // For detected M3U8 from body check, process inline + if (isM3u8) { + const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`; + const rewritten = rewriteM3u8(text, url, proxyBase); + return new NextResponse(rewritten, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }, + }); + } + // Not M3U8, return original text as binary-like response + return new NextResponse(text, { + status: response.status, + headers: { + 'Content-Type': contentType || 'video/mp2t', + 'Cache-Control': 'public, max-age=60', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }, + }); + } + const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', @@ -89,7 +133,7 @@ export async function GET(request: NextRequest) { if (isM3u8) { // Parse and rewrite manifest const text = await response.text(); - const proxyBase = `/api/iptv/stream?url=`; + const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`; const rewritten = rewriteM3u8(text, url, proxyBase); return new NextResponse(rewritten, { diff --git a/app/player/page.tsx b/app/player/page.tsx index c5998f8..b9d6bea 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -161,6 +161,7 @@ function PlayerContent() { sourceName: match.sourceDisplayName || getSourceName(match.source), latency: match.latency, pic: match.vod_pic, + typeName: match.type_name, }); // Update state incrementally setDiscoveredSources([...found]); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 99d9ca0..8e07268 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -11,6 +11,8 @@ import { AccountSettings } from '@/components/settings/AccountSettings'; import { DisplaySettings } from '@/components/settings/DisplaySettings'; import { PlayerSettings } from '@/components/settings/PlayerSettings'; import { SettingsHeader } from '@/components/settings/SettingsHeader'; +import { UserSourceSettings } from '@/components/settings/UserSourceSettings'; +import { UserDanmakuSettings } from '@/components/settings/UserDanmakuSettings'; import { PermissionGate } from '@/components/PermissionGate'; import { hasPermission } from '@/lib/store/auth-store'; import { useSettingsPage } from './hooks/useSettingsPage'; @@ -102,6 +104,12 @@ export default function SettingsPage() { onRememberScrollPositionChange={handleRememberScrollPositionChange} /> + {/* Per-User Source Settings (visible to all logged-in users) */} + + + {/* Per-User Danmaku Settings (visible to all logged-in users) */} + + {/* Source Management */} = { lowLatencyMode: true, liveDurationInfinity: true, manifestLoadingTimeOut: 10000, - manifestLoadingMaxRetry: 2, + manifestLoadingMaxRetry: 3, levelLoadingTimeOut: 10000, - fragLoadingTimeOut: 15000, + fragLoadingTimeOut: 20000, }; -const LOADING_TIMEOUT_MS = 20000; +const LOADING_TIMEOUT_MS = 30000; interface IPTVPlayerProps { channel: M3UChannel; @@ -30,8 +30,12 @@ interface IPTVPlayerProps { onChannelChange: (channel: M3UChannel) => void; } -function getProxiedUrl(url: string): string { - return `/api/iptv/stream?url=${encodeURIComponent(url)}`; +function getProxiedUrl(url: string, ua?: string, referer?: string): string { + let proxyUrl = `/api/iptv/stream?`; + if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`; + if (referer) proxyUrl += `referer=${encodeURIComponent(referer)}&`; + proxyUrl += `url=${encodeURIComponent(url)}`; + return proxyUrl; } function formatTime(seconds: number): string { @@ -161,7 +165,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV video.removeAttribute('src'); video.load(); - const proxiedUrl = getProxiedUrl(url); + const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer); // Global loading timeout let loadingResolved = false; @@ -309,7 +313,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV }, { once: true }); }, { once: true }); } - }, []); + }, [channel.httpUserAgent, channel.httpReferrer]); // Load on channel/route change useEffect(() => { diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index cdefba5..3e3d93a 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -20,6 +20,7 @@ export interface SourceInfo { sourceName?: string; latency?: number; pic?: string; + typeName?: string; } interface EpisodeListProps { @@ -47,6 +48,7 @@ export function EpisodeList({ const listRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); const [sourceExpanded, setSourceExpanded] = useState(false); + const [showAllSources, setShowAllSources] = useState(false); // Source latency state const [latencies, setLatencies] = useState>({}); @@ -233,74 +235,184 @@ export function EpisodeList({ 刷新延迟 -
- {sortedSources.map((source, index) => { - const isCurrent = source.source === currentSource; - const latency = latencies[source.source] ?? source.latency; + {(() => { + const MAX_VISIBLE = 5; + const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE); + const hasMoreSources = sortedSources.length > MAX_VISIBLE; - return ( - + ); + })}
+ )) + ) : ( + visibleSources.map((source, index) => { + const isCurrent = source.source === currentSource; + const latency = latencies[source.source] ?? source.latency; + + return ( + + ); + }) + )} + + {hasMoreSources && ( + - ); - })} - + + )} + + ); + })()} )} diff --git a/components/player/hooks/useDanmaku.ts b/components/player/hooks/useDanmaku.ts index 7f7e1dc..d56ffd6 100644 --- a/components/player/hooks/useDanmaku.ts +++ b/components/player/hooks/useDanmaku.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { settingsStore } from '@/lib/store/settings-store'; +import { userSourcesStore } from '@/lib/store/user-sources-store'; import { parseDanmakuResponse, parseSearchResults, matchEpisode, fuzzyMatchTitle } from '@/lib/utils/danmaku-utils'; import type { DanmakuComment } from '@/lib/types/danmaku'; @@ -27,18 +28,22 @@ export function useDanmaku({ videoTitle, episodeName, episodeIndex }: UseDanmaku const [error, setError] = useState(null); const fetchedKeyRef = useRef(''); - // Sync with settings store + // Sync with settings store + user danmaku API override useEffect(() => { - const s = settingsStore.getSettings(); - setDanmakuEnabledState(s.danmakuEnabled); - setApiUrl(s.danmakuApiUrl); + const updateApi = () => { + const s = settingsStore.getSettings(); + setDanmakuEnabledState(s.danmakuEnabled); - const unsub = settingsStore.subscribe(() => { - const ns = settingsStore.getSettings(); - setDanmakuEnabledState(ns.danmakuEnabled); - setApiUrl(ns.danmakuApiUrl); - }); - return unsub; + // User's active danmaku API takes priority over system setting + const userApi = userSourcesStore.getActiveDanmakuApi(); + setApiUrl(userApi ? userApi.url : s.danmakuApiUrl); + }; + + updateApi(); + + const unsub1 = settingsStore.subscribe(updateApi); + const unsub2 = userSourcesStore.subscribe(updateApi); + return () => { unsub1(); unsub2(); }; }, []); const setDanmakuEnabled = useCallback((v: boolean) => { diff --git a/components/search/SourceBadgeList.tsx b/components/search/SourceBadgeList.tsx index 5b507e4..c537871 100644 --- a/components/search/SourceBadgeList.tsx +++ b/components/search/SourceBadgeList.tsx @@ -6,7 +6,7 @@ 'use client'; -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Icons } from '@/components/ui/Icon'; import { SourceBadgeItem } from './SourceBadgeItem'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; @@ -17,6 +17,7 @@ interface Source { id: string; name: string; count: number; + typeName?: string; } interface SourceBadgeListProps { @@ -37,6 +38,19 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So const badgeContainerRef = useRef(null); const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]); + // Group sources by typeName if type info is available + const typeGroups = useMemo(() => { + const groups = new Map(); + for (const source of sources) { + const type = source.typeName || ''; + if (!groups.has(type)) groups.set(type, []); + groups.get(type)!.push(source); + } + // Only use grouping if there are meaningful type names + const hasTypes = groups.size > 1 || (groups.size === 1 && !groups.has('')); + return hasTypes ? groups : null; + }, [sources]); + const toggleExpanded = useCallback(() => { setIsExpanded(prev => { const next = !prev; @@ -97,19 +111,47 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So ref={badgeContainerRef} className="flex items-center gap-2 flex-wrap p-1" > - {sources.map((source, index) => ( - onToggleSource(source.id)} - isFocused={focusedIndex === index} - onFocus={() => setFocusedIndex(index)} - innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }} - /> - ))} + {typeGroups ? ( + Array.from(typeGroups.entries()).map(([typeName, typeSources]) => ( +
+ {typeName && ( + + {typeName}: + + )} + {typeSources.map((source) => { + const globalIndex = sources.indexOf(source); + return ( + onToggleSource(source.id)} + isFocused={focusedIndex === globalIndex} + onFocus={() => setFocusedIndex(globalIndex)} + innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[globalIndex] = el; }} + /> + ); + })} +
+ )) + ) : ( + sources.map((source, index) => ( + onToggleSource(source.id)} + isFocused={focusedIndex === index} + onFocus={() => setFocusedIndex(index)} + innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }} + /> + )) + )} diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx index 3de0141..4091fbc 100644 --- a/components/search/VideoCard.tsx +++ b/components/search/VideoCard.tsx @@ -90,11 +90,18 @@ export const VideoCard = memo(({ {/* Badge Container */}
- {video.sourceName && ( - - {video.sourceName} - - )} +
+ {video.sourceName && ( + + {video.sourceName} + + )} + {video.type_name && ( + + {video.type_name} + + )} +
{displayLatency !== undefined && ( diff --git a/components/search/VideoGroupCard.tsx b/components/search/VideoGroupCard.tsx index 7110c24..884acf6 100644 --- a/components/search/VideoGroupCard.tsx +++ b/components/search/VideoGroupCard.tsx @@ -66,6 +66,7 @@ export const VideoGroupCard = memo(({ sourceName: v.sourceName, latency: v.latency, pic: v.vod_pic, + typeName: v.type_name, })); params.set('groupedSources', JSON.stringify(groupData)); } diff --git a/components/settings/AccountSettings.tsx b/components/settings/AccountSettings.tsx index 170f7a7..a55cdfe 100644 --- a/components/settings/AccountSettings.tsx +++ b/components/settings/AccountSettings.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { getSession, clearSession, hasPermission, type Role } from '@/lib/store/auth-store'; +import { getSession, clearSession, hasPermission, type Role, type Permission } from '@/lib/store/auth-store'; import { SettingsSection } from './SettingsSection'; import { Icons } from '@/components/ui/Icon'; import { LogOut, Shield, Info } from 'lucide-react'; @@ -9,14 +9,32 @@ import { LogOut, Shield, Info } from 'lucide-react'; interface AccountInfo { name: string; role: Role; + customPermissions?: string[]; } interface ConfigEntry { password: string; name: string; role: Role; + customPermissions: Permission[]; } +const ALL_PERMISSIONS: { key: Permission; label: string }[] = [ + { key: 'source_management', label: '视频源管理' }, + { key: 'account_management', label: '账户管理' }, + { key: 'danmaku_api', label: '弹幕 API' }, + { key: 'data_management', label: '数据管理' }, + { key: 'player_settings', label: '播放器设置' }, + { key: 'danmaku_appearance', label: '弹幕外观' }, + { key: 'iptv_access', label: 'IPTV 访问' }, +]; + +const ROLE_PERMISSIONS: Record = { + super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'], + admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'], + viewer: ['view_settings'], +}; + export function AccountSettings() { const [session, setSessionState] = useState>(null); const [hasAuth, setHasAuth] = useState(false); @@ -54,7 +72,7 @@ export function AccountSettings() { // Config generator helpers const addConfigEntry = () => { - setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer' }]); + setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer', customPermissions: [] }]); }; const updateConfigEntry = (index: number, field: keyof ConfigEntry, value: string) => { @@ -63,6 +81,18 @@ export function AccountSettings() { setConfigEntries(updated); }; + const toggleConfigPermission = (index: number, perm: Permission) => { + const updated = [...configEntries]; + const entry = updated[index]; + const perms = entry.customPermissions || []; + if (perms.includes(perm)) { + entry.customPermissions = perms.filter(p => p !== perm); + } else { + entry.customPermissions = [...perms, perm]; + } + setConfigEntries(updated); + }; + const removeConfigEntry = (index: number) => { setConfigEntries(configEntries.filter((_, i) => i !== index)); }; @@ -70,7 +100,17 @@ export function AccountSettings() { const generateAccountsString = () => { return configEntries .filter(e => e.password.trim() && e.name.trim()) - .map(e => `${e.password}:${e.name}${e.role !== 'viewer' ? ':' + e.role : ''}`) + .map(e => { + let str = `${e.password}:${e.name}`; + const hasCustomPerms = e.customPermissions && e.customPermissions.length > 0; + if (e.role !== 'viewer' || hasCustomPerms) { + str += ':' + e.role; + } + if (hasCustomPerms) { + str += ':' + e.customPermissions.join('|'); + } + return str; + }) .join(','); }; @@ -92,6 +132,7 @@ export function AccountSettings() { password: '', name: a.name, role: a.role, + customPermissions: (a.customPermissions || []) as Permission[], })); setConfigEntries(existingEntries); setShowConfigGen(true); @@ -279,6 +320,30 @@ export function AccountSettings() {
+ {/* Custom permissions: show only those not in the selected role */} + {(() => { + const rolePerms = ROLE_PERMISSIONS[entry.role] || []; + const extraPerms = ALL_PERMISSIONS.filter(p => !rolePerms.includes(p.key)); + if (extraPerms.length === 0) return null; + return ( +
+ {extraPerms.map(p => { + const checked = entry.customPermissions?.includes(p.key) ?? false; + return ( + + ); + })} +
+ ); + })()} + + {error && ( +

{error}

+ )} + + + {/* API list with radio select */} +
+ {/* System default option */} + + + {apis.map(api => ( +
+ + +
+ ))} +
+ + + ); +} diff --git a/components/settings/UserSourceSettings.tsx b/components/settings/UserSourceSettings.tsx new file mode 100644 index 0000000..431b9d9 --- /dev/null +++ b/components/settings/UserSourceSettings.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { SettingsSection } from './SettingsSection'; +import { Icons } from '@/components/ui/Icon'; +import { userSourcesStore } from '@/lib/store/user-sources-store'; +import type { VideoSource } from '@/lib/types'; + +export function UserSourceSettings() { + const [sources, setSources] = useState([]); + const [name, setName] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + setSources(userSourcesStore.getSources()); + const unsub = userSourcesStore.subscribe(() => { + setSources(userSourcesStore.getSources()); + }); + return unsub; + }, []); + + const handleAdd = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !baseUrl.trim()) { + setError('名称和接口地址不能为空'); + return; + } + try { + new URL(baseUrl); + } catch { + setError('请输入有效的 URL'); + return; + } + + const id = `user-${name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now().toString(36)}`; + const source: VideoSource = { + id, + name: name.trim(), + baseUrl: baseUrl.trim(), + searchPath: '/provide/vod', + detailPath: '/provide/vod', + enabled: true, + }; + userSourcesStore.addSource(source); + setName(''); + setBaseUrl(''); + setError(''); + }; + + return ( + +
+ {/* Add form */} +
+
+ { setName(e.target.value); setError(''); }} + className="flex-1 px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" + /> + { setBaseUrl(e.target.value); setError(''); }} + className="flex-[2] px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" + /> + +
+ {error && ( +

{error}

+ )} +
+ + {/* Source list */} + {sources.length > 0 && ( +
+ {sources.map(source => ( +
+
+ +
+

{source.name}

+

{source.baseUrl}

+
+
+ +
+ ))} +
+ )} + + {sources.length === 0 && ( +

+ 还没有个人视频源,添加一个试试吧。 +

+ )} +
+
+ ); +} diff --git a/components/settings/import/ImportModalTabs.tsx b/components/settings/import/ImportModalTabs.tsx index b029830..3816a3a 100644 --- a/components/settings/import/ImportModalTabs.tsx +++ b/components/settings/import/ImportModalTabs.tsx @@ -1,8 +1,8 @@ 'use client'; interface ImportModalTabsProps { - activeTab: 'file' | 'link' | 'subscription'; - onTabChange: (tab: 'file' | 'link' | 'subscription') => void; + activeTab: 'file' | 'link' | 'subscription' | 'json'; + onTabChange: (tab: 'file' | 'link' | 'subscription' | 'json') => void; } export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps) { @@ -10,6 +10,7 @@ export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps { id: 'file', label: '文件导入' }, { id: 'link', label: '链接导入' }, { id: 'subscription', label: '订阅管理' }, + { id: 'json', label: 'JSON' }, ] as const; return ( diff --git a/components/settings/import/JsonImportTab.tsx b/components/settings/import/JsonImportTab.tsx new file mode 100644 index 0000000..5c032a9 --- /dev/null +++ b/components/settings/import/JsonImportTab.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useState } from 'react'; +import { Icons } from '@/components/ui/Icon'; +import { settingsStore } from '@/lib/store/settings-store'; +import type { VideoSource } from '@/lib/types'; + +export function JsonImportTab() { + const [jsonText, setJsonText] = useState(''); + const [error, setError] = useState(''); + const [preview, setPreview] = useState(null); + const [imported, setImported] = useState(false); + + const handleParse = () => { + setError(''); + setPreview(null); + if (!jsonText.trim()) { + setError('请粘贴 JSON 内容'); + return; + } + + try { + const parsed = JSON.parse(jsonText); + if (!Array.isArray(parsed)) { + setError('JSON 格式错误:应为数组'); + return; + } + + const sources: VideoSource[] = []; + for (let i = 0; i < parsed.length; i++) { + const item = parsed[i]; + if (!item.name || !item.baseUrl) { + setError(`第 ${i + 1} 项缺少 name 或 baseUrl`); + return; + } + sources.push({ + id: item.id || `json-${item.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${i}`, + name: item.name, + baseUrl: item.baseUrl, + searchPath: item.searchPath || '/provide/vod', + detailPath: item.detailPath || '/provide/vod', + enabled: item.enabled !== false, + headers: item.headers, + priority: item.priority, + group: item.group, + }); + } + + if (sources.length === 0) { + setError('没有有效的视频源'); + return; + } + + setPreview(sources); + } catch (e) { + setError('JSON 解析失败:' + (e instanceof Error ? e.message : '格式错误')); + } + }; + + const handleImport = () => { + if (!preview) return; + + const settings = settingsStore.getSettings(); + const existingIds = new Set(settings.sources.map(s => s.id)); + const newSources = preview.filter(s => !existingIds.has(s.id)); + const updatedSources = [...settings.sources, ...newSources]; + settingsStore.saveSettings({ ...settings, sources: updatedSources }); + setImported(true); + setPreview(null); + setJsonText(''); + }; + + return ( +
+

+ 粘贴 JSON 数组格式的视频源配置。格式: + + {'[{ "name": "...", "baseUrl": "..." }]'} + +

+ +