feat: Implement user-defined video sources and danmaku API management with new settings pages and a dedicated store.

This commit is contained in:
kuekhaoyang
2026-02-19 11:56:14 +08:00
parent e1ea1a0d9a
commit 9ea9f85507
26 changed files with 1097 additions and 151 deletions
+123 -30
View File
@@ -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**:所有用户数据按 profileIdSHA-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
+6 -1
View File
@@ -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 } : {}) });
}
}
});
+7 -1
View File
@@ -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,
});
}
}
+49 -5
View File
@@ -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, {
+1
View File
@@ -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]);
+8
View File
@@ -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
+1
View File
@@ -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
+11 -7
View File
@@ -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(() => {
+177 -65
View File
@@ -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>
+15 -10
View File
@@ -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) => {
+56 -14
View File
@@ -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>
+12 -5
View File
@@ -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" />
+1
View File
@@ -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));
}
+69 -4
View File
@@ -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>
+6 -1
View File
@@ -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>
+141
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+12 -2
View File
@@ -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]);
+4 -1
View File
@@ -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 {
+116
View File
@@ -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 });
},
};
+1
View File
@@ -51,6 +51,7 @@ export interface SourceBadge {
id: string;
name: string;
count: number;
typeName?: string;
}
export interface TypeBadge {
+6
View File
@@ -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(',');
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.4.2",
"version": "4.4.3",
"private": true,
"scripts": {
"dev": "next dev",