mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement user-defined video sources and danmaku API management with new settings pages and a dedicated store.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 } : {}) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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, {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) */}
|
||||
<UserSourceSettings />
|
||||
|
||||
{/* Per-User Danmaku Settings (visible to all logged-in users) */}
|
||||
<UserDanmakuSettings />
|
||||
|
||||
{/* Source Management */}
|
||||
<PermissionGate permission="source_management">
|
||||
<SourceSettings
|
||||
|
||||
@@ -80,6 +80,7 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
|
||||
profileId: data.profileId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
customPermissions: data.customPermissions,
|
||||
}, data.persistSession ?? persistSession);
|
||||
|
||||
// Reload to re-initialize stores with profiled keys
|
||||
|
||||
@@ -16,12 +16,12 @@ const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
|
||||
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(() => {
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [sourceExpanded, setSourceExpanded] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
|
||||
// Source latency state
|
||||
const [latencies, setLatencies] = useState<Record<string, number>>({});
|
||||
@@ -233,74 +235,184 @@ export function EpisodeList({
|
||||
刷新延迟
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
|
||||
{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 (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-2.5
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-0.5">
|
||||
<LatencyBadge latency={latency} />
|
||||
// Group sources by typeName
|
||||
const groupedByType = new Map<string, typeof visibleSources>();
|
||||
for (const source of visibleSources) {
|
||||
const typeName = source.typeName || '';
|
||||
if (!groupedByType.has(typeName)) groupedByType.set(typeName, []);
|
||||
groupedByType.get(typeName)!.push(source);
|
||||
}
|
||||
const hasTypeGroups = groupedByType.size > 1 || (groupedByType.size === 1 && !groupedByType.has(''));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
|
||||
{hasTypeGroups ? (
|
||||
Array.from(groupedByType.entries()).map(([typeName, typeSources]) => (
|
||||
<div key={typeName || '__default'}>
|
||||
{typeName && (
|
||||
<div className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-2 pt-2 pb-1">
|
||||
{typeName}
|
||||
</div>
|
||||
)}
|
||||
{typeSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const globalIndex = sortedSources.indexOf(source);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-2.5
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-0.5">
|
||||
<LatencyBadge latency={latency} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icons.Play size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
{!isCurrent && globalIndex < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${globalIndex === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
globalIndex === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{globalIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
visibleSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-2.5
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-0.5">
|
||||
<LatencyBadge latency={latency} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icons.Play size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
{!isCurrent && index < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{hasMoreSources && (
|
||||
<button
|
||||
onClick={() => setShowAllSources(!showAllSources)}
|
||||
className="w-full mt-1.5 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] flex items-center justify-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllSources ? (
|
||||
<>收起 <Icons.ChevronDown size={12} className="rotate-180" /></>
|
||||
) : (
|
||||
<>展开更多 ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icons.Play size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
{!isCurrent && index < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(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) => {
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Group sources by typeName if type info is available
|
||||
const typeGroups = useMemo(() => {
|
||||
const groups = new Map<string, Source[]>();
|
||||
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) => (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => 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]) => (
|
||||
<div key={typeName || '__default'} className="flex items-center gap-2 flex-wrap">
|
||||
{typeName && (
|
||||
<span className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-1 select-none">
|
||||
{typeName}:
|
||||
</span>
|
||||
)}
|
||||
{typeSources.map((source) => {
|
||||
const globalIndex = sources.indexOf(source);
|
||||
return (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === globalIndex}
|
||||
onFocus={() => setFocusedIndex(globalIndex)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[globalIndex] = el; }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
sources.map((source, index) => (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === index}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -90,11 +90,18 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
|
||||
{/* Badge Container */}
|
||||
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
|
||||
{video.sourceName && (
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{video.sourceName && (
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
)}
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="flex-shrink-0 max-w-[40%] truncate text-[10px]">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayLatency !== undefined && (
|
||||
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
|
||||
|
||||
@@ -66,6 +66,7 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
|
||||
sourceName: v.sourceName,
|
||||
latency: v.latency,
|
||||
pic: v.vod_pic,
|
||||
typeName: v.type_name,
|
||||
}));
|
||||
params.set('groupedSources', JSON.stringify(groupData));
|
||||
}
|
||||
|
||||
@@ -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<Role, Permission[]> = {
|
||||
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<ReturnType<typeof getSession>>(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() {
|
||||
<option value="super_admin">超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* 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 (
|
||||
<div className="flex flex-wrap gap-1.5 pl-1">
|
||||
{extraPerms.map(p => {
|
||||
const checked = entry.customPermissions?.includes(p.key) ?? false;
|
||||
return (
|
||||
<label key={p.key} className="flex items-center gap-1 text-[10px] text-[var(--text-color-secondary)] cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleConfigPermission(index, p.key)}
|
||||
className="w-3 h-3 rounded accent-[var(--accent-color)]"
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeConfigEntry(index)}
|
||||
@@ -331,7 +396,7 @@ export function AccountSettings() {
|
||||
</p>
|
||||
<div className="text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ADMIN_PASSWORD</code> — 单管理员密码</p>
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> — 多账户(密码:名称[:角色])</p>
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> — 多账户(密码:名称[:角色[:权限1|权限2]])</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ImportModalTabs } from './import/ImportModalTabs';
|
||||
import { FileImportTab } from './import/FileImportTab';
|
||||
import { LinkImportTab } from './import/LinkImportTab';
|
||||
import { SubscriptionImportTab } from './import/SubscriptionImportTab';
|
||||
import { JsonImportTab } from './import/JsonImportTab';
|
||||
import type { ImportResult } from '@/lib/utils/source-import-utils';
|
||||
import type { SourceSubscription } from '@/lib/types';
|
||||
import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
|
||||
@@ -33,7 +34,7 @@ export function ImportModal({
|
||||
onRemoveSubscription,
|
||||
onRefreshSubscription
|
||||
}: ImportModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription'>('file');
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription' | 'json'>('file');
|
||||
|
||||
// Reset tab on open
|
||||
useEffect(() => {
|
||||
@@ -90,6 +91,10 @@ export function ImportModal({
|
||||
onRefresh={onRefreshSubscription}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'json' && (
|
||||
<JsonImportTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
export function UserDanmakuSettings() {
|
||||
const [apis, setApis] = useState<DanmakuApiEntry[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [systemApiUrl, setSystemApiUrl] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const state = userSourcesStore.getState();
|
||||
setApis(state.danmakuApis);
|
||||
setActiveId(state.activeDanmakuApiId);
|
||||
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
|
||||
|
||||
const unsub = userSourcesStore.subscribe(() => {
|
||||
const s = userSourcesStore.getState();
|
||||
setApis(s.danmakuApis);
|
||||
setActiveId(s.activeDanmakuApiId);
|
||||
});
|
||||
const unsub2 = settingsStore.subscribe(() => {
|
||||
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
|
||||
});
|
||||
return () => { unsub(); unsub2(); };
|
||||
}, []);
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !url.trim()) {
|
||||
setError('名称和 URL 不能为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError('请输入有效的 URL');
|
||||
return;
|
||||
}
|
||||
const id = `danmaku-${Date.now().toString(36)}`;
|
||||
userSourcesStore.addDanmakuApi({ id, name: name.trim(), url: url.trim() });
|
||||
setName('');
|
||||
setUrl('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="弹幕 API" description="管理你的弹幕 API,选择当前使用的 API。">
|
||||
<div className="space-y-4">
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API 名称"
|
||||
value={name}
|
||||
onChange={(e) => { 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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API URL (https://...)"
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(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)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Icons.Plus size={14} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* API list with radio select */}
|
||||
<div className="space-y-2">
|
||||
{/* System default option */}
|
||||
<button
|
||||
onClick={() => userSourcesStore.setActiveDanmakuApi(null)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-left transition-all cursor-pointer ${
|
||||
activeId === null ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
activeId === null ? 'border-[var(--accent-color)]' : 'border-[var(--glass-border)]'
|
||||
}`}>
|
||||
{activeId === null && <span className="w-2 h-2 rounded-full bg-[var(--accent-color)]" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)]">使用系统默认</p>
|
||||
{systemApiUrl && (
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{systemApiUrl}</p>
|
||||
)}
|
||||
{!systemApiUrl && (
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)]">未配置系统弹幕 API</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{apis.map(api => (
|
||||
<div key={api.id} className={`flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] ${
|
||||
activeId === api.id ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => userSourcesStore.setActiveDanmakuApi(api.id)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
activeId === api.id ? 'border-[var(--accent-color)]' : 'border-[var(--glass-border)]'
|
||||
}`}>
|
||||
{activeId === api.id && <span className="w-2 h-2 rounded-full bg-[var(--accent-color)]" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{api.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{api.url}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => userSourcesStore.removeDanmakuApi(api.id)}
|
||||
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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<VideoSource[]>([]);
|
||||
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 (
|
||||
<SettingsSection title="个人视频源" description="添加你自己的视频源,不影响其他用户。">
|
||||
<div className="space-y-4">
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="源名称"
|
||||
value={name}
|
||||
onChange={(e) => { 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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="接口地址 (https://...)"
|
||||
value={baseUrl}
|
||||
onChange={(e) => { 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)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Icons.Plus size={14} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Source list */}
|
||||
{sources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sources.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => userSourcesStore.toggleSource(source.id)}
|
||||
className={`w-8 h-5 rounded-full transition-colors cursor-pointer relative flex-shrink-0 ${
|
||||
source.enabled !== false ? 'bg-[var(--accent-color)]' : 'bg-[var(--glass-border)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${
|
||||
source.enabled !== false ? 'left-3.5' : 'left-0.5'
|
||||
}`} />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{source.baseUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => userSourcesStore.removeSource(source.id)}
|
||||
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] text-center py-4">
|
||||
还没有个人视频源,添加一个试试吧。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<VideoSource[] | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
粘贴 JSON 数组格式的视频源配置。格式:
|
||||
<code className="block mt-1 px-2 py-1 bg-black/10 rounded text-[10px]">
|
||||
{'[{ "name": "...", "baseUrl": "..." }]'}
|
||||
</code>
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => { setJsonText(e.target.value); setError(''); setPreview(null); setImported(false); }}
|
||||
placeholder='[{ "name": "源名称", "baseUrl": "https://..." }]'
|
||||
rows={6}
|
||||
className="w-full 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)] font-mono resize-none"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{!preview && !imported && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
className="w-full px-4 py-2.5 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Icons.Search size={14} />
|
||||
解析预览
|
||||
</button>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<p className="text-sm text-[var(--text-color)]">
|
||||
解析成功,共 <span className="font-bold text-[var(--accent-color)]">{preview.length}</span> 个视频源
|
||||
</p>
|
||||
<div className="mt-2 space-y-1 max-h-[150px] overflow-y-auto">
|
||||
{preview.map((s, i) => (
|
||||
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2">
|
||||
<span className="font-medium text-[var(--text-color)]">{s.name}</span>
|
||||
<span className="truncate">{s.baseUrl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPreview(null); }}
|
||||
className="flex-1 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] rounded-[var(--radius-2xl)] text-sm hover:bg-[var(--glass-hover)] transition-all cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex-1 px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center justify-center gap-1"
|
||||
>
|
||||
<Icons.Download size={14} />
|
||||
导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imported && (
|
||||
<p className="text-xs text-green-500 text-center py-2">导入成功!</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useSearchCache } from '@/lib/hooks/useSearchCache';
|
||||
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { settingsStore, type SortOption } from '@/lib/store/settings-store';
|
||||
import { userSourcesStore } from '@/lib/store/user-sources-store';
|
||||
|
||||
export function useHomePage() {
|
||||
useSubscriptionSync();
|
||||
@@ -48,11 +49,20 @@ export function useHomePage() {
|
||||
const settings = settingsStore.getSettings();
|
||||
const enabledSources = settings.sources.filter(s => s.enabled);
|
||||
|
||||
if (enabledSources.length === 0) {
|
||||
// Merge user personal sources
|
||||
const userSources = userSourcesStore.getSources().filter(s => s.enabled !== false);
|
||||
const allSources = [...enabledSources];
|
||||
for (const us of userSources) {
|
||||
if (!allSources.find(s => s.id === us.id)) {
|
||||
allSources.push(us);
|
||||
}
|
||||
}
|
||||
|
||||
if (allSources.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
performSearch(searchQuery, enabledSources, settings.sortBy);
|
||||
performSearch(searchQuery, allSources, settings.sortBy);
|
||||
hasSearchedWithSourcesRef.current = true;
|
||||
return true;
|
||||
}, [performSearch]);
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AuthSession {
|
||||
profileId: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'kvideo-session';
|
||||
@@ -76,7 +77,9 @@ export function isAdmin(): boolean {
|
||||
export function hasPermission(permission: Permission): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
return ROLE_PERMISSIONS[session.role]?.includes(permission) ?? false;
|
||||
if (ROLE_PERMISSIONS[session.role]?.includes(permission)) return true;
|
||||
if (session.customPermissions?.includes(permission)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasRole(minimumRole: Role): boolean {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* User Sources Store - Per-user video sources and danmaku APIs
|
||||
* Uses localStorage keyed by profileId for isolation between users
|
||||
*/
|
||||
|
||||
import { getProfileId } from './auth-store';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export interface DanmakuApiEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface UserSourcesState {
|
||||
sources: VideoSource[];
|
||||
danmakuApis: DanmakuApiEntry[];
|
||||
activeDanmakuApiId: string | null;
|
||||
}
|
||||
|
||||
function getStorageKey(): string {
|
||||
const profileId = getProfileId();
|
||||
return `kvideo-user-sources-${profileId}`;
|
||||
}
|
||||
|
||||
function getState(): UserSourcesState {
|
||||
if (typeof window === 'undefined') {
|
||||
return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey());
|
||||
if (!raw) return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
||||
danmakuApis: Array.isArray(parsed.danmakuApis) ? parsed.danmakuApis : [],
|
||||
activeDanmakuApiId: parsed.activeDanmakuApiId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: UserSourcesState): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(state));
|
||||
userSourcesStore.notifyListeners();
|
||||
}
|
||||
|
||||
export const userSourcesStore = {
|
||||
listeners: new Set<() => void>(),
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
},
|
||||
|
||||
notifyListeners(): void {
|
||||
this.listeners.forEach(l => l());
|
||||
},
|
||||
|
||||
getState,
|
||||
|
||||
getSources(): VideoSource[] {
|
||||
return getState().sources;
|
||||
},
|
||||
|
||||
getDanmakuApis(): DanmakuApiEntry[] {
|
||||
return getState().danmakuApis;
|
||||
},
|
||||
|
||||
getActiveDanmakuApi(): DanmakuApiEntry | null {
|
||||
const state = getState();
|
||||
if (!state.activeDanmakuApiId) return null;
|
||||
return state.danmakuApis.find(a => a.id === state.activeDanmakuApiId) ?? null;
|
||||
},
|
||||
|
||||
addSource(source: VideoSource): void {
|
||||
const state = getState();
|
||||
if (state.sources.find(s => s.id === source.id)) return;
|
||||
saveState({ ...state, sources: [...state.sources, { ...source, enabled: true }] });
|
||||
},
|
||||
|
||||
removeSource(id: string): void {
|
||||
const state = getState();
|
||||
saveState({ ...state, sources: state.sources.filter(s => s.id !== id) });
|
||||
},
|
||||
|
||||
toggleSource(id: string): void {
|
||||
const state = getState();
|
||||
saveState({
|
||||
...state,
|
||||
sources: state.sources.map(s =>
|
||||
s.id === id ? { ...s, enabled: !s.enabled } : s
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
addDanmakuApi(entry: DanmakuApiEntry): void {
|
||||
const state = getState();
|
||||
if (state.danmakuApis.find(a => a.id === entry.id)) return;
|
||||
saveState({ ...state, danmakuApis: [...state.danmakuApis, entry] });
|
||||
},
|
||||
|
||||
removeDanmakuApi(id: string): void {
|
||||
const state = getState();
|
||||
const newApis = state.danmakuApis.filter(a => a.id !== id);
|
||||
const newActiveId = state.activeDanmakuApiId === id ? null : state.activeDanmakuApiId;
|
||||
saveState({ ...state, danmakuApis: newApis, activeDanmakuApiId: newActiveId });
|
||||
},
|
||||
|
||||
setActiveDanmakuApi(id: string | null): void {
|
||||
const state = getState();
|
||||
saveState({ ...state, activeDanmakuApiId: id });
|
||||
},
|
||||
};
|
||||
@@ -51,6 +51,7 @@ export interface SourceBadge {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
typeName?: string;
|
||||
}
|
||||
|
||||
export interface TypeBadge {
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface M3UChannel {
|
||||
routes?: string[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
httpUserAgent?: string;
|
||||
httpReferrer?: string;
|
||||
}
|
||||
|
||||
export interface M3UPlaylist {
|
||||
@@ -40,6 +42,8 @@ export function parseM3U(content: string): M3UPlaylist {
|
||||
const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/i);
|
||||
const groupTitleMatch = line.match(/group-title="([^"]*)"/i);
|
||||
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/i);
|
||||
const httpUserAgentMatch = line.match(/http-user-agent="([^"]*)"/i);
|
||||
const httpReferrerMatch = line.match(/http-referrer="([^"]*)"/i);
|
||||
|
||||
if (tvgNameMatch) channel.tvgName = tvgNameMatch[1];
|
||||
if (tvgLogoMatch) channel.logo = tvgLogoMatch[1];
|
||||
@@ -48,6 +52,8 @@ export function parseM3U(content: string): M3UPlaylist {
|
||||
if (channel.group) groupSet.add(channel.group);
|
||||
}
|
||||
if (tvgIdMatch) channel.tvgId = tvgIdMatch[1];
|
||||
if (httpUserAgentMatch) channel.httpUserAgent = httpUserAgentMatch[1];
|
||||
if (httpReferrerMatch) channel.httpReferrer = httpReferrerMatch[1];
|
||||
|
||||
// Extract channel name (after last comma)
|
||||
const commaIndex = line.lastIndexOf(',');
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.2",
|
||||
"version": "4.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.4.2",
|
||||
"version": "4.4.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.2",
|
||||
"version": "4.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user