mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3771b63332 | ||
|
|
59948b5085 | ||
|
|
f1a0086701 | ||
|
|
4983a9e2ab | ||
|
|
3db31b4d80 | ||
|
|
c64458bd6d | ||
|
|
3bf72e4b66 | ||
|
|
b63f94fed4 | ||
|
|
81c46d754e | ||
|
|
0675c28413 | ||
|
|
0294f59602 | ||
|
|
46f6eee2a6 | ||
|
|
cdab3b1c8a | ||
|
|
c53801d3bf | ||
|
|
6151edab4d | ||
|
|
907f393e2f | ||
|
|
1179148031 | ||
|
|
e04b306334 | ||
|
|
f98de077f7 | ||
|
|
c846c60d0c | ||
|
|
2ad9203e39 | ||
|
|
a1a3821538 | ||
|
|
387da736e1 | ||
|
|
dd4b7c9f24 | ||
|
|
b7e962b031 | ||
|
|
2a59e31cd4 | ||
|
|
68d4fd944d | ||
|
|
27d30139c1 | ||
|
|
8d558c79cf | ||
|
|
66d8564dd2 | ||
|
|
2ae0a03366 | ||
|
|
0f82ec5660 | ||
|
|
e135c6b084 | ||
|
|
7474979308 | ||
|
|
7ae08e2d7d | ||
|
|
d6f2a2e3c7 | ||
|
|
db72a74817 | ||
|
|
8c522119c0 | ||
|
|
2bb70ca648 | ||
|
|
5a76958816 | ||
|
|
610f57c5b0 | ||
|
|
d325603123 | ||
|
|
bacd3f887e | ||
|
|
93e6699937 | ||
|
|
89d1d7268b | ||
|
|
47b691a097 | ||
|
|
8ebc2ae009 | ||
|
|
787680468b | ||
|
|
0f39d65b75 | ||
|
|
62dafbfe59 | ||
|
|
dedf496761 | ||
|
|
f2667d42de | ||
|
|
6451ffb8ed | ||
|
|
a5f9293658 | ||
|
|
cb4593d947 | ||
|
|
5d17c72b3f | ||
|
|
db23dd732b | ||
|
|
7b57bd1a1f | ||
|
|
0111318f30 | ||
|
|
20c9ae3d56 | ||
|
|
c1bcb5141a | ||
|
|
f93f3ab82f | ||
|
|
eef52c751c | ||
|
|
d7ba651950 | ||
|
|
49951c67ef | ||
|
|
0f6060886c | ||
|
|
19e9707041 | ||
|
|
4f0f9308bb | ||
|
|
9d76bfdaf0 | ||
|
|
a205393da0 | ||
|
|
833077a1f6 | ||
|
|
4fb718f8d8 | ||
|
|
727fc2b0ed | ||
|
|
e2d7549a70 | ||
|
|
15f6e434c8 | ||
|
|
fb4f50ee7f | ||
|
|
372e60c4e5 | ||
|
|
4db08dbf72 | ||
|
|
e93c259e4a | ||
|
|
a380ed4133 | ||
|
|
f4f5597a80 | ||
|
|
ddcce8341a | ||
|
|
38fe8e00fb | ||
|
|
e73416d76b | ||
|
|
37b6e160d7 | ||
|
|
5829a79ee3 | ||
|
|
9762e0a6b5 | ||
|
|
f66ca2fd3e |
@@ -4,4 +4,8 @@ node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.vercel
|
||||
.wrangler
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.git
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
- cron: "0 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: upstream-sync-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
name: Android TV APK
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "android-tv/**"
|
||||
- ".github/workflows/android-tv-apk.yml"
|
||||
push:
|
||||
paths:
|
||||
- "android-tv/**"
|
||||
- ".github/workflows/android-tv-apk.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
@@ -27,11 +19,15 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: android-tv-apk-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_android_tv_apk:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
KVIDEO_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.kvideo_url || '' }}
|
||||
KVIDEO_URL: ${{ inputs.kvideo_url || '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android-tv
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.value }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read application version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
artifact: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
artifact: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push architecture image by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
outputs: type=image,name=kuekhaoyang/kvideo,push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.artifact }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.artifact }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.artifact }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs:
|
||||
- prepare
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: digest-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-architecture tags
|
||||
working-directory: /tmp/digests
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag "kuekhaoyang/kvideo:$VERSION" \
|
||||
--tag "kuekhaoyang/kvideo:latest" \
|
||||
$(printf 'kuekhaoyang/kvideo@sha256:%s ' *)
|
||||
|
||||
- name: Inspect published image
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: docker buildx imagetools inspect "kuekhaoyang/kvideo:$VERSION"
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Changelog
|
||||
|
||||
## 4.9.19 - 2026-07-31
|
||||
|
||||
- 修复 Docker / Node 自托管下 `/api/user/sync` 与 `/api/user/config` 无法从 `process.env` 读取 Upstash 凭据、持续返回 500 的问题;Redis 客户端改为按请求环境惰性创建,未配置同步时明确返回 503(#226 / PR #227)。
|
||||
- 云端恢复和历史/收藏列表渲染会过滤缺少 `videoId`、`source`、`title` 或有效 `episodeIndex` 的损坏记录;全部记录无效时正常显示空状态,不再让账户首页因渲染异常永久崩溃(#228 / PR #229)。
|
||||
- 豆瓣图片代理在 `imgN.doubanio.com` 线路不可达时按 `img9`、`img3`、`img2` 镜像回退,并保留最后一次上游响应状态,修复境外部署海报批量 500(PR #230)。
|
||||
- 新增同步记录和豆瓣镜像回退回归测试;真实 Next.js standalone + Upstash REST 兼容环境下的托管登录、同步读写与配置读写均验证通过。
|
||||
|
||||
## 4.9.18 - 2026-07-29
|
||||
|
||||
- 修复移动端播放器控制栏在窄屏下被裁切、最右侧“系统全屏”按钮不可见的问题(#224)。
|
||||
- 控制栏现在根据播放器自身宽度进入紧凑布局:隐藏窄屏音量入口、压缩间距,并在极窄播放器中只显示当前播放时间;画中画、AirPlay、Cast、网页全屏与系统全屏按钮继续保留。
|
||||
- 新增移动端播放器控制栏回归测试,锁定两个全屏入口在窄屏下不会再次被可选播放按钮挤出可视区。
|
||||
|
||||
## 4.9.17 - 2026-07-29
|
||||
|
||||
- 新增基于主内容时长残差的组合式 M3U8 插播检测;残差只作为辅助证据,单个 `.033/.333/.867` 等合法分片不再被直接删除。
|
||||
- 重复时长指纹恢复大内容块保护并限制出现比例,避免固定 GOP、等长章节或重复正片结构被整块误删。
|
||||
- 跨源同路径检测同时比较来源域名与文件名模式,在增强 Multi-CDN 插播识别的同时保留合法 CDN 切换的保护条件。
|
||||
- 安全审查后未启用基于 `new Function` 的动态 JS/TS 执行和公开脚本 API;该实现不是沙盒,且无法防止任意代码执行或主线程死循环。
|
||||
- 广告检测器拆分为解析、主模式、重复指纹、时长网格与评分模块,并补充误删回归测试。
|
||||
- Next.js 与配套 ESLint 配置升级至最新稳定版 16.2.12,消除 16.2.10 的多项框架级高风险公告;上游固定的 PostCSS/sharp 审计项仍等待官方稳定修复。
|
||||
|
||||
## 4.9.16 - 2026-07-27
|
||||
|
||||
- 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。
|
||||
- 收藏与历史浮动按钮现在会在窗口尺寸变化时保持各自的左右锚点;已拖拽的按钮按可拖拽区域比例保留位置。
|
||||
- 新增浮动按钮定位与窗口缩放回归测试。
|
||||
|
||||
## 4.9.15 - 2026-07-25
|
||||
|
||||
- 修复播放页点击选集“排序”导致播放器闪烁/重播:`usePlayerSettings` 在非播放器核心设置变化时复用快照引用,避免 HLS 实例被销毁重建。
|
||||
- 播放页布局对齐:视口控制移到内容区顶部,播放器与选集列顶部对齐;网页全屏按钮改用更贴合的框式图标。
|
||||
- 选集支持网格排版 + 分段翻页(每页 50 集),长剧集可快速跳转;仍可切回列表模式。
|
||||
- 源列表、视频详情与搜索卡片在封面缺失或加载失败时使用 `/placeholder-poster.svg` 占位图。
|
||||
- 合并并加固 PR #219 广告检测升级:新增 M3U8 分片时长指纹去重,同时保留代理 URL 解包、interstitial 元数据剥离与关键词归一化。
|
||||
- 修复 Android TV 9 / 旧 WebView 白屏:客户端静态资源目标从 `chrome83` 降到 `chrome69`,彻底去掉 `??` / `?.` / 逻辑赋值,兼容 Amlogic 等 Android 9 系统 WebView。
|
||||
- 新增广告检测、播放器设置快照与 WebView 69 回归测试。
|
||||
|
||||
## 4.9.13 - 2026-07-13
|
||||
|
||||
- 修复慢视频源超过 3 秒未返回时,前端提前结束搜索并错误显示“未找到结果”的问题;搜索现在等待服务端明确完成或响应流真正关闭。
|
||||
- 实时延迟探测现在使用视频源的真实 `baseUrl`,不再把源 ID 当成 URL;`HEAD` 失败后的 `GET` 探测独立计时,避免把前一次失败耗时叠加为虚高延迟。
|
||||
- 延迟探测改为最多 4 路并发并在上一轮结束后再调度下一轮,避免 Docker 自托管实例因慢源产生重叠探测请求。
|
||||
- 搜索请求取消或超时后不再继续重试,并清理级联 AbortSignal 监听器;新增慢源、探测回退、并发上限和取消重试回归测试。
|
||||
|
||||
## 4.9.12 - 2026-07-12
|
||||
|
||||
- 修复窄屏导航栏隐藏整个用户区域、导致登录用户无法退出的问题;退出按钮现在在所有视口宽度下保持可见,用户身份详情仍在桌面端显示。
|
||||
- 高级模式设置页复用统一的账户管理组件,恢复当前账户信息、退出登录以及超级管理员的账户新增、编辑和删除入口。
|
||||
- 新增账户入口回归测试,防止响应式样式或高级模式设置页再次移除关键认证操作。
|
||||
|
||||
## 4.9.11 - 2026-07-09
|
||||
|
||||
- 修复登录成功后仍停留在登录页、刷新后又要求登录的循环问题。
|
||||
- `PasswordGate` 登录成功后会直接解除客户端锁定,不再依赖整页刷新;服务端 session 存在但本地镜像缺失时也会直接补齐会话并进入应用。
|
||||
- 服务端 session cookie 的 `Secure` 属性改为按实际请求协议和 `x-forwarded-proto` 判断,避免 Docker / HTTP 自托管生产模式下浏览器拒收登录 cookie。
|
||||
- 新增登录 gate 状态决策和 session cookie 协议判断回归测试。
|
||||
|
||||
## 4.9.10 - 2026-07-09
|
||||
|
||||
- 从首页移除硬编码的快捷搜索区,不再显示“内容分类”“电影类型”“年份”三组按钮。
|
||||
- 标签管理入口和首页推荐区现在直接从内容类型切换与后端标签列表开始,避免顶部快捷搜索继续占用空间。
|
||||
- 更新标签管理视图回归测试,锁定首页不再引用 `CategoryQuickSearch`。
|
||||
|
||||
## 4.9.9 - 2026-07-09
|
||||
|
||||
- 修复首页管理标签入口混入快捷搜索流程的问题,打开管理模式后不再显示“内容分类”“电影类型”“年份”等搜索快捷项。
|
||||
- 管理标签时隐藏电影/电视剧切换和推荐内容网格,让“完成 / 恢复默认 / 添加标签 / 标签列表”成为当前视图主体。
|
||||
- 标签管理列表改为可换行 chip 布局,并在管理模式下使用矩形拖拽排序策略,避免标签区域被横向滚动行挤压。
|
||||
- 新增标签管理视图回归测试,防止管理入口再次退化为搜索筛选界面。
|
||||
|
||||
## 4.9.8 - 2026-07-09
|
||||
|
||||
- 修复登录完成跳转后 Network 面板出现 `/api/user/config` 和 `/api/user/sync` 两个 400 请求的问题。
|
||||
- `AutoSync` 改为在 `PasswordGate` 确认服务端 session 并放行页面内容后再挂载,避免本地旧 session 早于服务端 cookie 校验触发云同步。
|
||||
- 用户配置和云同步 API 在未认证时返回 `401 Authentication required`,不再使用 `400 Missing profileId` 表达会话缺失。
|
||||
- 新增未认证响应回归测试,锁定受保护同步接口的状态码语义。
|
||||
|
||||
## 4.9.7 - 2026-07-09
|
||||
|
||||
- 修复 Android TV / 小米电视旧 WebView 83 打开站点后白屏的问题。
|
||||
- Next.js 构建后的浏览器静态 JS 会额外转译到 `chrome83`,避免 `??=`、`||=`、`&&=` 等逻辑赋值语法让旧 WebView 在解析阶段直接失败。
|
||||
- Cloudflare Pages 输出目录同样会执行兼容性转译,确保 Docker/Node 自托管和 Cloudflare Pages 部署的浏览器资产一致。
|
||||
- 新增 WebView 83 静态资源回归测试,防止不兼容语法重新进入构建产物。
|
||||
|
||||
## 4.9.6 - 2026-07-09
|
||||
|
||||
- 修复移动端 Safari 网页横屏时弹幕字体被异常拉伸或压缩的问题。
|
||||
- 弹幕画布改为优先读取未受 CSS transform 影响的布局尺寸,避免强制横屏旋转时使用横竖互换的 bounding box。
|
||||
- 横竖屏、窗口尺寸和 `visualViewport` 变化后会重新同步画布 backing store,并按新尺寸重排活跃弹幕。
|
||||
- 新增弹幕画布尺寸工具测试,覆盖 CSS transform 造成视觉尺寸与布局尺寸不一致的场景。
|
||||
|
||||
## 4.9.5 - 2026-07-09
|
||||
|
||||
- Vercel Analytics 现在只会在真实 Vercel 部署中注入,Docker、自托管和 Cloudflare Pages 不再请求 `/_vercel/insights/script.js`。
|
||||
- Cloudflare 部署环境判定优先于 Vercel 风格环境变量,避免适配器环境被误判为 Vercel。
|
||||
- 新增部署环境单元测试,覆盖自托管、Vercel、Cloudflare 和混合环境变量场景。
|
||||
|
||||
## 4.9.4 - 2026-07-08
|
||||
|
||||
- 设置页的代理播放区域显示内置 `/api/proxy` 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。
|
||||
- 新增 `ALLOW_LAN_ACCESS` 环境变量:开启后 `npm run dev` / `npm run start` 会绑定 `0.0.0.0`,开发模式会放行常见内网 IPv4 来源。
|
||||
- 新增 `LAN_ALLOWED_DEV_ORIGINS`,允许自定义 Next.js 开发模式的额外来源主机。
|
||||
- README 补充局域网访问和内置媒体代理说明,并同步修正 Docker hostname 注释。
|
||||
|
||||
## 4.9.3 - 2026-04-15
|
||||
|
||||
- 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。
|
||||
- 认证改为服务端签名 HTTP-only 会话 Cookie,`/api/user/config` 与 `/api/user/sync` 不再信任客户端自报 `profileId`。
|
||||
- 播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。
|
||||
- 仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 `eslint` 版本与 Next.js 规则链不兼容的问题。
|
||||
|
||||
## 4.9.2 - 2026-04-12
|
||||
|
||||
- 设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。
|
||||
- 移除会反复闪烁的“正在整理结果”提示,避免用户误以为页面异常。
|
||||
- 手动点击“检查更新”时才显示按钮转圈,状态反馈与用户操作保持一致。
|
||||
|
||||
## 4.9.1 - 2026-04-12
|
||||
|
||||
- 移除仅支持 Node.js 的 `/api/site-icon` 路由,避免 `next-on-pages` 因非 Edge 路由中止构建。
|
||||
- 站点图标改为由服务端布局直接解析,导航栏和播放器页继续显示运行时配置的图标。
|
||||
- PWA manifest 回退为静态图标,确保 Cloudflare Pages、Docker 和本地构建链路一致可用。
|
||||
|
||||
## 4.9.0 - 2026-04-12
|
||||
|
||||
- 设置页新增“版本与更新”卡片,直接显示当前版本、最近更新内容和检查结果。
|
||||
- 新增手动“检查更新”按钮,会对比 GitHub `main` 分支上的最新版本元数据。
|
||||
- 仓库新增更新日志文件,方便在项目内查看当前版本和更新说明。
|
||||
|
||||
## 4.8.3 - 2026-04-12
|
||||
|
||||
- 站点图标改为支持运行时配置,Docker 镜像无需重新构建即可替换。
|
||||
- 扩展播放器默认视口,减少桌面端播放器黑边。
|
||||
- 修复 Android WebView 中 Cast SDK 判定以及全屏和画中画相关兼容性问题。
|
||||
+1
-1
@@ -79,7 +79,7 @@ USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
# set hostname to localhost
|
||||
# Bind the standalone server to all container interfaces.
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
**KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4,打造了一个既美观又强大的视频浏览平台。
|
||||
|
||||
> **说明**:仓库默认不内置任何视频源、高级源或 IPTV 源。部署者必须自行配置已获授权、可合法使用且允许当前部署方式访问的内容来源。
|
||||
|
||||
### 核心设计理念:Liquid Glass(液态玻璃)
|
||||
|
||||
项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言:
|
||||
@@ -255,9 +257,31 @@
|
||||
|
||||
## 账户与访问控制
|
||||
|
||||
KVideo 支持基于环境变量的账户认证系统,支持角色区分和细粒度权限控制。
|
||||
KVideo 现在支持两套认证模式:
|
||||
|
||||
### 方式一:单管理员密码
|
||||
- **托管账户模式(推荐)**:配置 `AUTH_SECRET` + Upstash Redis 后,登录改为 **用户名 + 密码**,超级管理员可直接在设置页创建、修改、重置和删除账户。
|
||||
- **环境变量模式(兼容旧部署)**:未启用托管账户时,继续使用 `ADMIN_PASSWORD` / `ACCESS_PASSWORD` / `ACCOUNTS` 进行密码登录。
|
||||
|
||||
### 方式一:托管账户模式(推荐)
|
||||
|
||||
启用条件:
|
||||
|
||||
- 配置 `AUTH_SECRET`
|
||||
- 配置 `UPSTASH_REDIS_REST_URL`
|
||||
- 配置 `UPSTASH_REDIS_REST_TOKEN`
|
||||
- (可选)配置 `MANAGED_AUTH_ENABLED=true` 强制优先使用托管账户模式
|
||||
|
||||
启用后:
|
||||
|
||||
- 主登录页使用 **用户名 + 密码**
|
||||
- 服务端使用 HTTP-only 签名会话 Cookie 作为认证真源
|
||||
- 超级管理员可在设置页直接管理账户和权限
|
||||
- 配置同步、历史、收藏等跨设备数据会按登录账户自动隔离
|
||||
|
||||
> **强制优先托管模式**:设置 `MANAGED_AUTH_ENABLED=true` 只会改变认证模式选择,不会跳过 `AUTH_SECRET` 和 Redis 检查。缺少 `AUTH_SECRET`、`UPSTASH_REDIS_REST_URL` 或 `UPSTASH_REDIS_REST_TOKEN` 时,托管账户模式仍不会启用。
|
||||
首次启用时,如果 Redis 里还没有账户,会自动使用 `ADMIN_PASSWORD` 和 `ACCOUNTS` 作为引导种子创建首批托管账户。
|
||||
|
||||
### 方式二:单管理员密码(环境变量模式)
|
||||
|
||||
通过 `ADMIN_PASSWORD` 环境变量设置管理员密码:
|
||||
|
||||
@@ -270,11 +294,16 @@ docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhao
|
||||
|
||||
> **向后兼容**:`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
|
||||
|
||||
### 方式二:多账户系统
|
||||
### 方式三:多账户系统(环境变量模式)
|
||||
|
||||
通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
|
||||
|
||||
**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。
|
||||
**兼容格式:**
|
||||
|
||||
- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]`
|
||||
- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]`
|
||||
|
||||
多个账户之间用逗号分隔。
|
||||
|
||||
- **角色**:`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
|
||||
- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
|
||||
@@ -320,7 +349,9 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
|
||||
|
||||
### 方式三:高级内容独立密码
|
||||
> 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。
|
||||
|
||||
### 方式四:高级内容独立密码
|
||||
|
||||
通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
|
||||
|
||||
@@ -340,7 +371,7 @@ docker run -d -p 3000:3000 \
|
||||
- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
|
||||
- 不设置此变量时,高级内容无额外密码保护
|
||||
|
||||
### 方式四:会话持久化设置
|
||||
### 方式五:会话持久化设置
|
||||
|
||||
通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话:
|
||||
|
||||
@@ -376,15 +407,6 @@ docker run -d -p 3000:3000 \
|
||||
- 变量名:`NEXT_PUBLIC_SITE_NAME`
|
||||
- 变量值:`我的视频平台`
|
||||
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e NEXT_PUBLIC_SITE_NAME="我的视频平台" \
|
||||
-e NEXT_PUBLIC_SITE_TITLE="我的视频 - 聚合播放平台" \
|
||||
-e NEXT_PUBLIC_SITE_DESCRIPTION="专属视频聚合播放平台" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**本地开发:**
|
||||
在项目根目录创建 `.env.local` 文件:
|
||||
```env
|
||||
@@ -393,6 +415,44 @@ NEXT_PUBLIC_SITE_TITLE=我的视频 - 聚合播放平台
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `NEXT_PUBLIC_SITE_*` 属于构建时变量。直接运行 Docker Hub 的预构建镜像时,`docker run -e NEXT_PUBLIC_SITE_* ...` 不会覆盖已经打包进前端的文案。
|
||||
|
||||
## Docker 图标自定义
|
||||
|
||||
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会作用于顶部 Logo 和浏览器 favicon;如果你还要同步替换安装后的 PWA 图标,请直接覆盖仓库中的 `public/icon.png` 后重新构建镜像。
|
||||
|
||||
### 可用环境变量:
|
||||
|
||||
| 变量名 | 说明 |
|
||||
|--------|------|
|
||||
| `SITE_ICON_FILE` | 从容器内文件路径读取图标,适合 Docker 挂载,优先级高于 `SITE_ICON_URL` |
|
||||
| `SITE_ICON_URL` | 直接使用外部 URL 或站内路径作为图标 |
|
||||
|
||||
### 配置示例:
|
||||
|
||||
**Docker 挂载文件(推荐):**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-v /path/to/icon.png:/app/custom/icon.png:ro \
|
||||
-e SITE_ICON_FILE=/app/custom/icon.png \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**Docker 使用 URL:**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e SITE_ICON_URL="https://example.com/icon.png" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**Docker 使用站内路径:**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e SITE_ICON_URL="/placeholder-poster.svg" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
## 自动订阅源配置
|
||||
|
||||
可以通过环境变量自动配置订阅源,应用启动时会自动加载并设置为自动更新。
|
||||
@@ -476,11 +536,11 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
## 一起看 (VideoTogether) 配置
|
||||
|
||||
播放器页面和 IPTV 页面默认内置 [VideoTogether](https://videotogether.github.io/zh-cn/guide/website.html) 官方网页集成脚本,进入播放页后即可直接使用一起看功能。
|
||||
播放器页面和 IPTV 页面支持集成 [VideoTogether](https://videotogether.github.io/zh-cn/guide/website.html) 官方网页脚本。应用内默认关闭,用户可在设置页手动开启;开启后仅在播放器和 IPTV 页面显示,并默认折叠为小图标。
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `VIDEOTOGETHER_ENABLED` | 设为 `false` 时禁用一起看集成 | `true` |
|
||||
| `VIDEOTOGETHER_ENABLED` | 设为 `false` 时彻底禁用一起看集成(即使用户在设置页开启也不会加载) | `true` |
|
||||
| `VIDEOTOGETHER_SCRIPT_URL` | 自定义 VideoTogether 脚本地址,适合自托管或替换 CDN | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` |
|
||||
| `VIDEOTOGETHER_SETTING_URL` | 自定义 VideoTogether 设置页地址,对应官方 `window.videoTogetherWebsiteSettingUrl` 接口 | - |
|
||||
|
||||
@@ -500,6 +560,8 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。
|
||||
|
||||
> **注意**:在 Vercel / Cloudflare 托管部署的合规模式下,IPTV 页面与流中继默认关闭,`IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` 不会生效。
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `IPTV_SOURCES` | IPTV 直播源配置(服务端) | - |
|
||||
@@ -549,6 +611,43 @@ PORT=8080 npm run start
|
||||
docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
## 局域网 IP 访问
|
||||
|
||||
通过 `ALLOW_LAN_ACCESS` 环境变量控制本机开发或传统 Node.js 自托管时是否允许同一局域网设备直接访问当前设备 IP。
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `ALLOW_LAN_ACCESS` | 设为 `true` / `1` / `yes` / `on` 时,`npm run dev` 和 `npm run start` 会绑定 `0.0.0.0`,并在开发模式下放行常见内网 IPv4 来源;未开启时默认只绑定 `localhost` | `false` |
|
||||
| `NEXT_PUBLIC_ALLOW_LAN_ACCESS` | `ALLOW_LAN_ACCESS` 的构建时兼容名称;优先使用 `ALLOW_LAN_ACCESS` | `false` |
|
||||
| `LAN_ALLOWED_DEV_ORIGINS` | 追加 Next.js 开发模式允许访问 dev 资源的主机名或通配符,逗号、空格或换行分隔 | - |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 开发模式允许同局域网设备访问
|
||||
ALLOW_LAN_ACCESS=true npm run dev
|
||||
|
||||
# 传统 Node.js 生产模式允许同局域网设备访问
|
||||
ALLOW_LAN_ACCESS=true npm run start
|
||||
|
||||
# 追加自定义开发来源;Next.js 需要主机名,不要填写路径
|
||||
ALLOW_LAN_ACCESS=true LAN_ALLOWED_DEV_ORIGINS="kvideo.lan,192.168.50.10" npm run dev
|
||||
```
|
||||
|
||||
开启后访问地址通常是 `http://<当前设备内网 IP>:3000`。如果仍然无法访问,优先检查系统防火墙、路由器客户端隔离、端口映射、反向代理监听地址以及实际运行端口;这些不是 KVideo 前端代码可以绕过的问题。Docker 镜像已经在容器内绑定 `0.0.0.0`,是否能从局域网访问主要取决于 `-p` 端口映射和宿主机网络策略。
|
||||
|
||||
## 内置媒体代理说明
|
||||
|
||||
设置页的“代理播放模式”使用的是当前 KVideo 部署内置的 `/api/proxy` 媒体转发端点,不是让用户填写第三方 HTTP/SOCKS 代理服务器。
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| 智能重试 | 优先直连,播放失败后自动切换到 `/api/proxy` |
|
||||
| 仅直连 | 不使用 `/api/proxy`,失败时直接报错 |
|
||||
| 总是代理 | 播放地址始终通过 `/api/proxy` 转发 |
|
||||
|
||||
`/api/proxy?url=<encoded-video-url>` 只在 Docker 或传统 Node.js 自托管完整模式下启用。Vercel / Cloudflare 托管部署运行合规模式,会禁用外部媒体代理、热链转发和 IPTV 流中继;这种部署环境下设置页会显示仅支持直连播放。
|
||||
|
||||
## 自定义源 JSON 格式
|
||||
|
||||
如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。
|
||||
@@ -619,15 +718,21 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `ADMIN_PASSWORD` | 管理员密码 | - |
|
||||
| `AUTH_SECRET` | 托管账户模式的会话签名密钥;启用 Redis 托管账户时必填 | - |
|
||||
| `MANAGED_AUTH_ENABLED` | 设为 `true` 时强制优先使用托管账户模式;仍要求 `AUTH_SECRET` 和 Upstash Redis 配置完整 | - |
|
||||
| `ADMIN_PASSWORD` | 管理员密码;环境变量模式直接生效,也可作为托管模式首次引导的超级管理员种子 | - |
|
||||
| `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - |
|
||||
| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - |
|
||||
| `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]` 和 `用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - |
|
||||
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
|
||||
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
|
||||
| `PORT` | 自定义应用端口 | `3000` |
|
||||
| `ALLOW_LAN_ACCESS` / `NEXT_PUBLIC_ALLOW_LAN_ACCESS` | 允许同局域网设备通过当前设备 IP 访问本机开发或传统 Node.js 自托管服务 | `false` |
|
||||
| `LAN_ALLOWED_DEV_ORIGINS` / `NEXT_PUBLIC_LAN_ALLOWED_DEV_ORIGINS` | 追加 Next.js 开发模式允许访问 dev 资源的主机名或通配符 | - |
|
||||
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
|
||||
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
|
||||
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
|
||||
| `SITE_ICON_FILE` | Docker 运行时图标文件路径(优先于 `SITE_ICON_URL`) | - |
|
||||
| `SITE_ICON_URL` | Docker 运行时图标 URL 或站内路径 | - |
|
||||
| `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - |
|
||||
| `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - |
|
||||
| `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - |
|
||||
@@ -635,7 +740,7 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
| `AD_KEYWORDS` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - |
|
||||
| `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - |
|
||||
| `DANMAKU_API_URL` / `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - |
|
||||
| `VIDEOTOGETHER_ENABLED` | 是否启用 VideoTogether 一起看集成(`false` 时关闭) | `true` |
|
||||
| `VIDEOTOGETHER_ENABLED` | 是否允许 VideoTogether 一起看集成(`false` 时关闭) | `true` |
|
||||
| `VIDEOTOGETHER_SCRIPT_URL` | VideoTogether 脚本地址 | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` |
|
||||
| `VIDEOTOGETHER_SETTING_URL` | VideoTogether 设置页地址 | - |
|
||||
| `UPSTASH_REDIS_REST_URL` | Upstash Redis REST URL(跨设备同步:配置、历史、收藏) | - |
|
||||
@@ -660,13 +765,13 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
|
||||
- **ESLint 10**:代码质量检查
|
||||
- **PostCSS 8**:CSS 处理器
|
||||
- **Vercel Analytics**:性能监控和分析
|
||||
- **Vercel Analytics**:仅在 Vercel 部署中启用的性能监控和分析
|
||||
- **Cloudflare Pages**:边缘部署支持
|
||||
|
||||
### 架构特点
|
||||
|
||||
- **App Router**:Next.js 的新路由系统,支持服务端组件和流式渲染
|
||||
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、视频源代理、IPTV 代理和弹幕聚合
|
||||
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、弹幕聚合,以及仅在自托管完整模式下启用的媒体代理 / IPTV 中继
|
||||
- **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果
|
||||
- **Service Worker**:PWA 支持和资源缓存
|
||||
- **Server Components**:优化首屏加载性能
|
||||
@@ -674,45 +779,25 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
- **Edge Runtime**:API 路由运行在 Edge Runtime 上,提供更低延迟
|
||||
- **Profile-based Storage**:所有用户数据按 profileId(SHA-256 哈希)隔离存储
|
||||
|
||||
## 部署合规说明
|
||||
|
||||
Issue [#127](https://github.com/KuekHaoYang/KVideo/issues/127) 之后,仓库的公开部署策略做了调整,目的只有一个:避免把这个项目继续包装成“GitHub 账号直连第三方平台的一键导入仓库”。
|
||||
|
||||
- 不再推荐任何 `Deploy with Vercel`、`Connect GitHub`、`Fork 后直接授权第三方读取仓库` 这一类 GitHub 直连导入流程。
|
||||
- Vercel / Cloudflare 托管部署现在默认运行在**合规模式**:自动关闭外部媒体代理、热链转发和 IPTV 流中继,仅保留直连播放路径。
|
||||
- 需要 `/api/proxy`、`/api/iptv/stream`、自定义 `User-Agent` / `Referer` 转发、IPTV 中继等能力时,请使用 Docker 或传统 Node.js 自托管。
|
||||
- 无论部署到哪里,都只应接入你有权使用、且允许当前部署环境访问的内容来源。
|
||||
- 相关政策请直接阅读官方原文:
|
||||
- [GitHub Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service)
|
||||
- [GitHub Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)
|
||||
- [Vercel: why running another CDN / proxy on top of Vercel is not recommended](https://vercel.com/guides/why-running-another-cdn-on-top-of-vercel-is-not-recommended)
|
||||
- [Cloudflare abuse approach](https://www.cloudflare.com/trust-hub/abuse-approach/)
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 部署到自己的服务器
|
||||
|
||||
#### 选项 1:Vercel 一键部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/KuekHaoYang/KVideo)
|
||||
|
||||
1. 点击上方按钮
|
||||
2. 连接你的 GitHub 账号
|
||||
3. Vercel 会自动检测 Next.js 项目并部署
|
||||
4. 几分钟后即可访问你自己的 KVideo 实例
|
||||
|
||||
#### 选项 2:Cloudflare Pages 部署 (推荐)
|
||||
|
||||
此方法完全免费且速度极快,是部署本项目的最佳选择。
|
||||
|
||||
1. **Fork 本仓库**:首先将项目 Fork 到你的 GitHub 账户。
|
||||
|
||||
2. **创建项目**:
|
||||
- 点击访问 [**Cloudflare Pages - Connect Git**](https://dash.cloudflare.com/?to=/:account/pages/new/provider/github)。
|
||||
- 如果未连接 GitHub,请点击 **Connect GitHub**;若已连接,直接选择你刚刚 Fork 的 `KVideo` 项目并点击 **Begin setup**。
|
||||
|
||||
3. **配置构建参数**:
|
||||
- **Project name**: 默认为 `kvideo` (建议保持不变,后续链接基于此名称)
|
||||
- **Framework Preset**: 选择 `Next.js`
|
||||
- **Build command**: 输入 `npm run pages:build`
|
||||
- **Build output directory**: 输入 `.vercel/output/static`
|
||||
- 点击 **Save and Deploy**。
|
||||
|
||||
4. **等待部署完成**:
|
||||
> 项目已内置 `wrangler.toml` 配置文件,其中包含 `nodejs_compat` 兼容性标志,无需手动配置。
|
||||
|
||||
5. **如果部署失败**:
|
||||
> 如果看到 `Unknown internal error occurred` 错误,这通常是 Cloudflare 的临时服务端问题。请在 **Deployments** 列表中找到最新部署,点击 `...` 菜单选择 **Retry deployment** 重试即可。
|
||||
>
|
||||
> 如果首次部署仍有问题,可尝试在 **[项目设置页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo/settings/production)** 底部的 **Compatibility flags** 中手动添加 `nodejs_compat`,然后重新部署。
|
||||
|
||||
#### 选项 3:Docker 部署
|
||||
#### 选项 1:Docker 部署(推荐,完整功能)
|
||||
|
||||
**从 Docker Hub 拉取(最简单):**
|
||||
|
||||
@@ -758,7 +843,7 @@ docker run -d -p 3000:3000 \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
#### 选项 4:传统 Node.js 部署
|
||||
#### 选项 2:传统 Node.js 部署(完整功能)
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
@@ -777,7 +862,30 @@ npm start
|
||||
|
||||
应用将在 `http://localhost:3000` 启动。
|
||||
|
||||
#### 选项 5:Android TV APK 构建
|
||||
同一局域网设备需要直接访问当前设备 IP 时,使用 `ALLOW_LAN_ACCESS=true npm run start`,并确认系统防火墙允许入站访问对应端口。
|
||||
|
||||
#### 选项 3:Vercel / Cloudflare 托管部署(合规模式)
|
||||
|
||||
适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。
|
||||
|
||||
1. **只从本地工作副本部署**:不要使用 GitHub 一键导入、Fork 后导入、Connect GitHub 读取仓库等流程。
|
||||
2. **Vercel**:使用本地 CLI 部署现有项目,参考官方文档:[Import an existing project](https://vercel.com/docs/getting-started-with-vercel/import)。
|
||||
3. **Cloudflare**:使用官方 **Direct Upload** 或 CLI 工作流,不要使用 Git integration,参考官方文档:[Getting started with Pages](https://developers.cloudflare.com/pages/get-started)。
|
||||
4. **Cloudflare Direct Upload 正确流程**:
|
||||
```bash
|
||||
npm install
|
||||
npm run pages:build
|
||||
```
|
||||
构建完成后,上传 **`.vercel/output/static`** 目录,而不是仓库根目录、`.next` 目录或源码文件。上传仓库根目录只会把源码当静态文件托管,根路径没有构建产物可供 Pages 入口加载,结果就是 404。
|
||||
5. **Cloudflare CLI 发布**:如果你使用 CLI,请发布同一个构建产物目录:
|
||||
```bash
|
||||
npx wrangler pages deploy .vercel/output/static
|
||||
```
|
||||
6. **Cloudflare 构建链路**:`npm run pages:build` 现在固定使用仓库内安装的 `next-on-pages` 和兼容版本的 `vercel`,避免构建时临时拉取不受控版本。`wrangler.toml` 继续提供 `nodejs_compat` 兼容标志。
|
||||
7. **功能限制**:托管平台会自动禁用外部媒体代理和 IPTV 流中继;请仅使用允许浏览器直连、允许当前来源访问且具备合法授权的内容源。
|
||||
8. **如果你需要完整能力**:直接改用 Docker 或传统 Node.js 自托管,不要在托管平台上强行恢复这些能力。
|
||||
|
||||
#### 选项 4:Android TV APK 构建
|
||||
|
||||
项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。
|
||||
|
||||
@@ -829,7 +937,7 @@ npm start
|
||||
>
|
||||
> **发布方式**:GitHub Actions 的 `Android TV APK` 工作流会持续验证壳应用可构建;如需对外发布预构建 APK,可手动触发该工作流并填写 `release_tag`,生成对应的 GitHub Release 资产。
|
||||
|
||||
#### 选项 6:Apple TV 应用构建
|
||||
#### 选项 5:Apple TV 应用构建
|
||||
|
||||
项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。
|
||||
|
||||
@@ -865,7 +973,18 @@ npm start
|
||||
|
||||
### Vercel 部署
|
||||
|
||||
Vercel 会自动检测 GitHub 仓库的更新并重新部署,无需手动操作。
|
||||
不要依赖 GitHub 仓库自动导入。请在本地工作副本完成更新后,使用 Vercel CLI 重新发布当前目录。
|
||||
|
||||
### Cloudflare 托管部署
|
||||
|
||||
请在本地工作副本完成更新后,重新执行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run pages:build
|
||||
```
|
||||
|
||||
然后使用 Direct Upload 或 CLI 发布 **`.vercel/output/static`**。不要上传仓库根目录,也不要上传 `.next`,否则 Pages 只会托管源码文件,访问站点时直接 404。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
@@ -901,6 +1020,27 @@ npm start
|
||||
|
||||
这是 Cloudflare 的临时服务端错误,与代码无关。请在 Deployments 列表中重试部署即可。项目已内置 `wrangler.toml` 配置 `nodejs_compat` 兼容性标志。
|
||||
|
||||
### Cloudflare Pages Direct Upload 部署后打开是 404
|
||||
|
||||
这通常不是应用路由问题,而是上传了错误的目录。Cloudflare Pages 的 Direct Upload 需要上传已经构建完成的产物目录。
|
||||
|
||||
正确做法:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run pages:build
|
||||
```
|
||||
|
||||
然后上传 **`.vercel/output/static`**。
|
||||
|
||||
错误做法:
|
||||
|
||||
- 上传仓库根目录
|
||||
- 上传 `.next`
|
||||
- 直接把源码压缩包丢给 Direct Upload
|
||||
|
||||
这些做法都不会生成 Pages 可执行入口,部署成功后打开站点就会是 404。
|
||||
|
||||
### IPv6 环境下 HTTPS 访问视频无法播放
|
||||
|
||||
如果你的网络使用 IPv6 访问,且通过路由器端口映射(如 20443 → 443),请确保:
|
||||
@@ -916,12 +1056,14 @@ Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的
|
||||
|
||||
### IPTV 部分直播流无法播放
|
||||
|
||||
如果你部署在 Vercel / Cloudflare 托管平台,IPTV 在合规模式下是**默认禁用**的;这不是 Bug,而是刻意限制。下面的说明只适用于 Docker / Node.js 自托管完整模式。
|
||||
|
||||
浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
|
||||
- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer)
|
||||
- 某些加密或受 DRM 保护的流
|
||||
- 需要特定客户端验证的流
|
||||
|
||||
KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
|
||||
在自托管完整模式下,KVideo 的 IPTV 代理会处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
|
||||
|
||||
### IPTV CCTV 等频道只有声音没有画面
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
@@ -26,9 +26,11 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape">
|
||||
android:screenOrientation="landscape"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<!-- Leanback launcher (Android TV home) -->
|
||||
<intent-filter>
|
||||
|
||||
@@ -2,55 +2,67 @@ package com.kvideo.tv
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebChromeClient.CustomViewCallback
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "kvideo_tv_settings"
|
||||
private const val PREF_SERVER_URL = "server_url"
|
||||
private const val TAG = "KVideoMainActivity"
|
||||
}
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var setupContainer: View
|
||||
private lateinit var fullscreenContainer: FrameLayout
|
||||
private lateinit var urlInput: EditText
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var openButton: Button
|
||||
private lateinit var saveButton: Button
|
||||
private lateinit var prefs: android.content.SharedPreferences
|
||||
private var customView: View? = null
|
||||
private var customViewCallback: CustomViewCallback? = null
|
||||
private var wasSetupVisibleBeforeFullscreen = false
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Fullscreen immersive mode
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
)
|
||||
applyImmersiveMode()
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
webView = findViewById(R.id.webview)
|
||||
setupContainer = findViewById(R.id.setup_container)
|
||||
fullscreenContainer = findViewById(R.id.fullscreen_container)
|
||||
urlInput = findViewById(R.id.url_input)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
openButton = findViewById(R.id.open_button)
|
||||
@@ -59,6 +71,24 @@ class MainActivity : ComponentActivity() {
|
||||
saveButton.setOnClickListener {
|
||||
openConfiguredUrl()
|
||||
}
|
||||
urlInput.setOnEditorActionListener { _, actionId, event ->
|
||||
val isKeyboardConfirmAction = actionId == EditorInfo.IME_NULL ||
|
||||
actionId == EditorInfo.IME_ACTION_DONE ||
|
||||
actionId == EditorInfo.IME_ACTION_GO ||
|
||||
actionId == EditorInfo.IME_ACTION_SEND ||
|
||||
actionId == EditorInfo.IME_ACTION_NEXT
|
||||
val isEnterKey = event?.action == KeyEvent.ACTION_DOWN && (
|
||||
event.keyCode == KeyEvent.KEYCODE_ENTER ||
|
||||
event.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||
)
|
||||
|
||||
if (isKeyboardConfirmAction || isEnterKey) {
|
||||
openConfiguredUrl()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
webView.apply {
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
@@ -75,7 +105,41 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
webViewClient = WebViewClient()
|
||||
webChromeClient = WebChromeClient()
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
if (view == null || callback == null) {
|
||||
callback?.onCustomViewHidden()
|
||||
return
|
||||
}
|
||||
|
||||
if (customView != null) {
|
||||
callback.onCustomViewHidden()
|
||||
return
|
||||
}
|
||||
|
||||
wasSetupVisibleBeforeFullscreen = isSetupVisible()
|
||||
customView = view
|
||||
customViewCallback = callback
|
||||
|
||||
fullscreenContainer.removeAllViews()
|
||||
fullscreenContainer.addView(
|
||||
view,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
fullscreenContainer.visibility = View.VISIBLE
|
||||
webView.visibility = View.GONE
|
||||
setupContainer.visibility = View.GONE
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
override fun onHideCustomView() {
|
||||
exitCustomFullscreen()
|
||||
}
|
||||
}
|
||||
addJavascriptInterface(AndroidPlayerBridge(), "KVideoAndroid")
|
||||
}
|
||||
|
||||
val configuredUrl = getConfiguredUrl()
|
||||
@@ -88,6 +152,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (!isSetupVisible() && keyCode == KeyEvent.KEYCODE_BACK && customView != null) {
|
||||
exitCustomFullscreen()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isSetupVisible() && (keyCode == KeyEvent.KEYCODE_MENU || keyCode == KeyEvent.KEYCODE_SETTINGS)) {
|
||||
showSetup(getString(R.string.status_settings_hint))
|
||||
return true
|
||||
@@ -111,6 +180,11 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Deprecated("Use OnBackPressedDispatcher")
|
||||
override fun onBackPressed() {
|
||||
if (customView != null) {
|
||||
exitCustomFullscreen()
|
||||
return
|
||||
}
|
||||
|
||||
if (isSetupVisible()) {
|
||||
@Suppress("DEPRECATION")
|
||||
super.onBackPressed()
|
||||
@@ -124,7 +198,31 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
applyImmersiveMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
dispatchPictureInPictureChange(isInPictureInPictureMode)
|
||||
if (!isInPictureInPictureMode) {
|
||||
applyImmersiveMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
exitCustomFullscreen()
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -210,4 +308,118 @@ class MainActivity : ComponentActivity() {
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
return (scheme == "http" || scheme == "https") && !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun exitCustomFullscreen() {
|
||||
val currentCustomView = customView ?: return
|
||||
fullscreenContainer.removeView(currentCustomView)
|
||||
fullscreenContainer.visibility = View.GONE
|
||||
customView = null
|
||||
webView.visibility = View.VISIBLE
|
||||
if (wasSetupVisibleBeforeFullscreen) {
|
||||
setupContainer.visibility = View.VISIBLE
|
||||
}
|
||||
customViewCallback?.onCustomViewHidden()
|
||||
customViewCallback = null
|
||||
wasSetupVisibleBeforeFullscreen = false
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
private fun isPictureInPictureSupported(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return false
|
||||
}
|
||||
|
||||
return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
}
|
||||
|
||||
private fun dispatchPictureInPictureChange(isInPictureInPictureMode: Boolean) {
|
||||
val js = """
|
||||
window.dispatchEvent(new CustomEvent('kvideo-android-pip-change', {
|
||||
detail: { inPictureInPicture: ${if (isInPictureInPictureMode) "true" else "false"} }
|
||||
}));
|
||||
""".trimIndent()
|
||||
|
||||
webView.post {
|
||||
try {
|
||||
webView.evaluateJavascript(js, null)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Failed to dispatch Picture-in-Picture change event", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSourceRectHint(left: Int, top: Int, right: Int, bottom: Int): Rect? {
|
||||
if (right <= left || bottom <= top) {
|
||||
return null
|
||||
}
|
||||
|
||||
val density = resources.displayMetrics.density
|
||||
return Rect(
|
||||
(left * density).roundToInt(),
|
||||
(top * density).roundToInt(),
|
||||
(right * density).roundToInt(),
|
||||
(bottom * density).roundToInt()
|
||||
)
|
||||
}
|
||||
|
||||
private inner class AndroidPlayerBridge {
|
||||
@JavascriptInterface
|
||||
fun isPictureInPictureSupported(): Boolean = this@MainActivity.isPictureInPictureSupported()
|
||||
|
||||
@JavascriptInterface
|
||||
fun enterPictureInPicture(
|
||||
width: Int,
|
||||
height: Int,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int
|
||||
): Boolean {
|
||||
if (!this@MainActivity.isPictureInPictureSupported()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val didEnterPiP = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
runOnUiThread {
|
||||
try {
|
||||
val builder = android.app.PictureInPictureParams.Builder()
|
||||
if (width > 0 && height > 0) {
|
||||
builder.setAspectRatio(Rational(width, height))
|
||||
}
|
||||
createSourceRectHint(left, top, right, bottom)?.let(builder::setSourceRectHint)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setSeamlessResizeEnabled(true)
|
||||
}
|
||||
didEnterPiP.set(enterPictureInPictureMode(builder.build()))
|
||||
} catch (error: IllegalStateException) {
|
||||
Log.w(TAG, "Failed to enter Picture-in-Picture mode", error)
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
latch.await(1500, TimeUnit.MILLISECONDS)
|
||||
didEnterPiP.get()
|
||||
} catch (error: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
Log.w(TAG, "Interrupted while waiting for Picture-in-Picture result", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,101 +9,114 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
<ScrollView
|
||||
android:id="@+id/setup_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#E6101115"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#F31A1C22"
|
||||
android:elevation="12dp"
|
||||
android:maxWidth="680dp"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_title"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/setup_description"
|
||||
android:textColor="#D7DBE5"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/url_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:background="#FF0D0F14"
|
||||
android:hint="@string/setup_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textUri"
|
||||
android:minHeight="56dp"
|
||||
android:paddingHorizontal="18dp"
|
||||
android:paddingVertical="14dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#7D8597"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/status_ready"
|
||||
android:textColor="#F5B942"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
android:background="#F31A1C22"
|
||||
android:elevation="12dp"
|
||||
android:maxWidth="680dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/open_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/button_open_saved"
|
||||
android:textAllCaps="false"
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_title"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/setup_description"
|
||||
android:textColor="#D7DBE5"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/save_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/button_save"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="18sp" />
|
||||
<EditText
|
||||
android:id="@+id/url_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:background="#FF0D0F14"
|
||||
android:hint="@string/setup_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textUri"
|
||||
android:minHeight="56dp"
|
||||
android:paddingHorizontal="18dp"
|
||||
android:paddingVertical="14dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textColorHint="#7D8597"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/status_ready"
|
||||
android:textColor="#F5B942"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/open_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/button_open_saved"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/save_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/button_save"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="18sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/setup_footer"
|
||||
android:textColor="#9EA7BA"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/setup_footer"
|
||||
android:textColor="#9EA7BA"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/fullscreen_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"repository": {
|
||||
"owner": "KuekHaoYang",
|
||||
"name": "KVideo",
|
||||
"branch": "main"
|
||||
},
|
||||
"currentVersion": "4.9.19",
|
||||
"releases": [
|
||||
{
|
||||
"version": "4.9.19",
|
||||
"publishedAt": "2026-07-31",
|
||||
"title": "修复自托管同步与豆瓣海报回退",
|
||||
"notes": [
|
||||
"修复 Docker / Node 自托管下用户同步与配置接口无法从 process.env 读取 Upstash 凭据、持续返回 500 的问题(#226 / PR #227)。",
|
||||
"云端恢复与列表渲染会丢弃缺少 videoId、source、title 或有效 episodeIndex 的损坏记录,避免账户首页被永久崩溃数据锁死(#228 / PR #229)。",
|
||||
"豆瓣图片代理会在 imgN.doubanio.com 线路不可达时按镜像回退,并保留上游最终状态码,修复境外部署海报批量 500(PR #230)。",
|
||||
"新增同步记录与豆瓣镜像回退回归测试,并通过真实 Next.js standalone + Upstash REST 兼容环境的登录、同步和配置读写验证。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.18",
|
||||
"publishedAt": "2026-07-29",
|
||||
"title": "修复移动端系统全屏按钮",
|
||||
"notes": [
|
||||
"修复移动端播放器控制栏在窄屏下被裁切、最右侧系统全屏按钮不可见的问题(#224)。",
|
||||
"控制栏按播放器自身宽度切换紧凑布局,窄屏隐藏音量入口并压缩间距,极窄播放器只显示当前播放时间。",
|
||||
"画中画、AirPlay、Cast、网页全屏与系统全屏按钮全部保留,并新增响应式控制栏回归测试。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.17",
|
||||
"publishedAt": "2026-07-29",
|
||||
"title": "加固 M3U8 广告检测",
|
||||
"notes": [
|
||||
"新增基于主内容时长残差的组合式检测,识别跨帧率小型插播,同时禁止把单个异常时长分片直接判为广告。",
|
||||
"重复时长指纹恢复大内容块保护并限制出现比例,避免固定 GOP 或等长章节被整块误删。",
|
||||
"跨源同路径检测同时比较来源域名与文件名模式,并保留合法 CDN 切换所需的组合证据。",
|
||||
"安全审查后未启用动态 JS/TS 执行与公开脚本 API,避免任意代码执行、主线程死循环和无效配置链路。",
|
||||
"广告检测器按解析、主模式、指纹与评分拆分,并新增误删回归测试。",
|
||||
"Next.js 与配套 ESLint 配置升级至最新稳定版 16.2.12,消除 16.2.10 的多项框架级高风险公告;上游固定的 PostCSS/sharp 审计项仍等待官方稳定修复。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.16",
|
||||
"publishedAt": "2026-07-27",
|
||||
"title": "修复浮动按钮窗口缩放吸附",
|
||||
"notes": [
|
||||
"修复窗口宽度变化后右侧“历史播放记录”按钮停留在原位置、无法继续贴靠右边缘的问题。",
|
||||
"收藏与历史浮动按钮现在会在窗口尺寸变化时保持各自的左右锚点;已拖拽的按钮按可拖拽区域比例保留位置。",
|
||||
"新增浮动按钮定位与窗口缩放回归测试。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.15",
|
||||
"publishedAt": "2026-07-25",
|
||||
"title": "播放页体验与 Android TV 9 兼容",
|
||||
"notes": [
|
||||
"修复点击选集排序导致播放器闪烁重播:非播放器核心设置变化不再重建 HLS。",
|
||||
"播放页布局对齐、网页全屏图标更新;选集支持网格+分段翻页;封面缺失显示占位图。",
|
||||
"广告检测新增分片时长指纹去重(PR #219),并保留原有 interstitial/代理/关键词过滤。",
|
||||
"客户端静态资源转译目标降至 chrome69,修复 Android TV 9 旧 WebView 白屏。",
|
||||
"同步补齐广告检测、设置快照与 WebView 回归测试。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.13",
|
||||
"publishedAt": "2026-07-13",
|
||||
"title": "修复慢源搜索与延迟探测",
|
||||
"notes": [
|
||||
"慢视频源不再因 3 秒静默被前端提前判定为搜索完成,结果流会等待服务端明确结束。",
|
||||
"实时延迟改用真实视频源地址并独立计算回退请求耗时,避免无效 URL 和累加计时造成虚高数值。",
|
||||
"延迟探测限制为最多 4 路并发且轮次不重叠,搜索取消或超时后也不会继续无意义重试。",
|
||||
"新增慢源搜索、延迟回退、探测并发和取消重试回归测试。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.12",
|
||||
"publishedAt": "2026-07-12",
|
||||
"title": "恢复账户管理与退出登录入口",
|
||||
"notes": [
|
||||
"窄屏导航栏现在始终显示退出登录按钮,不再把整个用户操作区隐藏在桌面断点之后。",
|
||||
"高级模式设置页现在复用统一账户管理组件,恢复账户信息、退出登录及超级管理员账户管理功能。",
|
||||
"新增账户入口回归测试,覆盖窄屏导航栏和高级模式设置页。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.11",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复登录成功后回到登录页",
|
||||
"notes": [
|
||||
"访问密码 gate 登录成功后会直接放行当前页面,不再依赖整页刷新进入主界面,避免会话镜像时序导致重复登录。",
|
||||
"服务端 session cookie 的 Secure 属性现在按实际请求协议和 x-forwarded-proto 判断,修复 Docker / HTTP 自托管生产模式下浏览器拒收 cookie 后刷新又回登录页的问题。",
|
||||
"新增登录 gate 状态决策和 session cookie 协议判断回归测试。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.10",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "移除首页顶部快捷搜索区",
|
||||
"notes": [
|
||||
"首页不再显示内容分类、电影类型、年份三组硬编码快捷搜索按钮。",
|
||||
"推荐页顶部直接进入内容类型切换与后端标签列表,减少标签区域上方的无关占用。",
|
||||
"更新标签管理视图回归测试,防止首页重新引用 CategoryQuickSearch。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.9",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复管理标签入口误入搜索流程",
|
||||
"notes": [
|
||||
"首页管理标签模式现在会隐藏内容分类、电影类型、年份等快捷搜索项,避免用户打开标签管理时触发视频源搜索。",
|
||||
"管理标签时不再显示电影/电视剧切换和推荐内容网格,完成、恢复默认、添加标签和标签列表成为当前视图主体。",
|
||||
"标签 chip 在管理模式下改为可换行布局,并使用矩形拖拽排序策略,减少上方筛选内容对标签操作区的挤压。",
|
||||
"新增标签管理视图回归测试,锁定管理入口不应退化为搜索结果筛选界面。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.8",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复登录后同步接口 400",
|
||||
"notes": [
|
||||
"自动同步组件现在只会在访问密码 gate 确认服务端会话并完成本地 session 镜像后挂载,避免登录跳转阶段提前请求 /api/user/config 与 /api/user/sync。",
|
||||
"用户配置和云同步 API 在没有有效服务端 session 时改为返回 401 Authentication required,不再把未认证状态伪装成 Missing profileId 的 400。",
|
||||
"新增未认证响应回归测试,防止受保护同步接口的状态码语义再次退化。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.7",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复旧 Android WebView 白屏",
|
||||
"notes": [
|
||||
"Next.js 构建后的浏览器静态 JS 现在会额外转译到 Chrome/WebView 83 目标,避免小米电视等旧 Android System WebView 因不支持 ??=、||=、&&= 而白屏。",
|
||||
"Cloudflare Pages 构建链路同样会处理输出目录,确保托管站点和 Docker/Node 自托管站点使用一致的旧 WebView 兼容产物。",
|
||||
"新增 WebView 83 静态资源回归测试,防止逻辑赋值语法重新漏进浏览器构建产物。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.6",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复移动端横屏弹幕缩放",
|
||||
"notes": [
|
||||
"弹幕画布现在优先使用未受 CSS transform 影响的布局尺寸,避免 iOS Safari 网页横屏时字体被拉伸或压缩。",
|
||||
"横竖屏和 visualViewport 尺寸变化后会重新同步画布 backing store,并按新尺寸重排活跃弹幕。",
|
||||
"新增弹幕画布尺寸工具测试,覆盖 transform 后 bounding box 横竖互换的回归场景。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.5",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复 Docker 登录页 Analytics 404",
|
||||
"notes": [
|
||||
"Vercel Analytics 现在只会在真实 Vercel 部署中注入,Docker、自托管和 Cloudflare Pages 不再请求 /_vercel/insights/script.js。",
|
||||
"Cloudflare 部署环境判定优先于 Vercel 风格环境变量,避免适配器环境被误判为 Vercel。",
|
||||
"新增部署环境单元测试,覆盖自托管、Vercel、Cloudflare 和混合环境变量场景。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.4",
|
||||
"publishedAt": "2026-07-08",
|
||||
"title": "补充代理说明与局域网访问开关",
|
||||
"notes": [
|
||||
"设置页的代理播放区域显示内置 /api/proxy 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。",
|
||||
"新增 ALLOW_LAN_ACCESS 环境变量:开启后 npm run dev / npm run start 会绑定 0.0.0.0,开发模式会放行常见内网 IPv4 来源。",
|
||||
"新增 LAN_ALLOWED_DEV_ORIGINS,允许自定义 Next.js 开发模式的额外来源主机。",
|
||||
"README 补充局域网访问和内置媒体代理说明,并同步修正 Docker hostname 注释。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.3",
|
||||
"publishedAt": "2026-04-15",
|
||||
"title": "新增 Redis 托管账户模式",
|
||||
"notes": [
|
||||
"新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。",
|
||||
"认证改为服务端签名 HTTP-only 会话 Cookie,/api/user/config 与 /api/user/sync 不再信任客户端自报 profileId。",
|
||||
"播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。",
|
||||
"仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 eslint 版本与 Next.js 规则链不兼容的问题。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.2",
|
||||
"publishedAt": "2026-04-12",
|
||||
"title": "修复检查更新状态闪烁",
|
||||
"notes": [
|
||||
"设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。",
|
||||
"移除会反复闪烁的“正在整理结果”提示,避免用户误以为页面异常。",
|
||||
"手动点击“检查更新”时才显示按钮转圈,状态反馈与用户操作保持一致。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.1",
|
||||
"publishedAt": "2026-04-12",
|
||||
"title": "修复 Cloudflare Pages 构建失败",
|
||||
"notes": [
|
||||
"移除仅支持 Node.js 的 /api/site-icon 路由,避免 next-on-pages 因非 Edge 路由中止构建。",
|
||||
"站点图标改为由服务端布局直接解析,导航栏和播放器页继续显示运行时配置的图标。",
|
||||
"PWA manifest 回退为静态图标,确保 Cloudflare Pages、Docker 和本地构建链路一致可用。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.0",
|
||||
"publishedAt": "2026-04-12",
|
||||
"title": "设置页新增版本信息与更新检查",
|
||||
"notes": [
|
||||
"设置页新增“版本与更新”卡片,直接显示当前版本、最近更新内容和检查结果。",
|
||||
"新增手动“检查更新”按钮,会对比 GitHub main 分支上的最新版本元数据。",
|
||||
"仓库新增更新日志文件,方便在项目内查看当前版本和更新说明。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.8.3",
|
||||
"publishedAt": "2026-04-12",
|
||||
"title": "站点图标与播放器兼容性修复",
|
||||
"notes": [
|
||||
"站点图标改为支持运行时配置,Docker 镜像无需重新构建即可替换。",
|
||||
"扩展播放器默认视口,减少桌面端播放器黑边。",
|
||||
"修复 Android WebView 中 Cast SDK 判定以及全屏和画中画相关兼容性问题。"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
APP_VERSION,
|
||||
compareVersions,
|
||||
getDefaultRepositoryBranch,
|
||||
getDefaultRepositorySlug,
|
||||
getReleaseByVersion,
|
||||
LOCAL_RELEASE_MANIFEST,
|
||||
} from '@/lib/app-release';
|
||||
import type {
|
||||
AppReleaseEntry,
|
||||
AppReleaseManifest,
|
||||
AppUpdateResponse,
|
||||
} from '@/lib/types/app-update';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MANIFEST_PATH = 'app-release.json';
|
||||
const CHANGELOG_PATH = 'CHANGELOG.md';
|
||||
|
||||
function parseRepositoryTarget(repository: string) {
|
||||
const [owner, name] = repository.split('/', 2);
|
||||
|
||||
if (!owner || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
function buildSourceInfo(repository: string, branch: string) {
|
||||
const target = parseRepositoryTarget(repository);
|
||||
|
||||
if (!target) {
|
||||
const emptyUrl = 'https://github.com';
|
||||
return {
|
||||
repository,
|
||||
branch,
|
||||
manifestUrl: emptyUrl,
|
||||
changelogUrl: emptyUrl,
|
||||
repositoryUrl: emptyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const { owner, name } = target;
|
||||
|
||||
return {
|
||||
repository,
|
||||
branch,
|
||||
manifestUrl: `https://raw.githubusercontent.com/${owner}/${name}/${branch}/${MANIFEST_PATH}`,
|
||||
changelogUrl: `https://github.com/${owner}/${name}/blob/${branch}/${CHANGELOG_PATH}`,
|
||||
repositoryUrl: `https://github.com/${owner}/${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidReleaseEntry(value: unknown): value is AppReleaseEntry {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = value as Partial<AppReleaseEntry>;
|
||||
|
||||
return (
|
||||
typeof entry.version === 'string' &&
|
||||
typeof entry.publishedAt === 'string' &&
|
||||
typeof entry.title === 'string' &&
|
||||
Array.isArray(entry.notes) &&
|
||||
entry.notes.every((note) => typeof note === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeManifest(
|
||||
value: unknown,
|
||||
repository: string,
|
||||
branch: string,
|
||||
): AppReleaseManifest | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifest = value as Partial<AppReleaseManifest>;
|
||||
const target = parseRepositoryTarget(repository);
|
||||
|
||||
if (!target || typeof manifest.currentVersion !== 'string' || !Array.isArray(manifest.releases)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releases = manifest.releases.filter(isValidReleaseEntry);
|
||||
|
||||
if (releases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
repository: {
|
||||
owner: manifest.repository?.owner || target.owner,
|
||||
name: manifest.repository?.name || target.name,
|
||||
branch: manifest.repository?.branch || branch,
|
||||
},
|
||||
currentVersion: manifest.currentVersion,
|
||||
releases,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
repository: string,
|
||||
branch: string,
|
||||
overrides: Partial<AppUpdateResponse> = {},
|
||||
): AppUpdateResponse {
|
||||
const source = buildSourceInfo(repository, branch);
|
||||
const currentRelease = getReleaseByVersion(APP_VERSION);
|
||||
|
||||
return {
|
||||
currentVersion: APP_VERSION,
|
||||
currentRelease,
|
||||
latestVersion: APP_VERSION,
|
||||
latestRelease: currentRelease,
|
||||
status: 'up-to-date',
|
||||
updateAvailable: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
checkedRemotely: false,
|
||||
usedRemoteManifest: false,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const repository =
|
||||
process.env.UPDATE_REPOSITORY?.trim() ||
|
||||
process.env.NEXT_PUBLIC_UPDATE_REPOSITORY?.trim() ||
|
||||
getDefaultRepositorySlug();
|
||||
const branch =
|
||||
process.env.UPDATE_BRANCH?.trim() ||
|
||||
process.env.NEXT_PUBLIC_UPDATE_BRANCH?.trim() ||
|
||||
getDefaultRepositoryBranch();
|
||||
|
||||
const source = buildSourceInfo(repository, branch);
|
||||
|
||||
try {
|
||||
const remoteManifestJson = await fetchJson<unknown>(source.manifestUrl);
|
||||
const remoteManifest = normalizeManifest(remoteManifestJson, repository, branch);
|
||||
|
||||
if (remoteManifest) {
|
||||
const latestVersion = remoteManifest.currentVersion;
|
||||
const latestRelease =
|
||||
getReleaseByVersion(latestVersion, remoteManifest) ?? remoteManifest.releases[0] ?? null;
|
||||
const comparison = compareVersions(latestVersion, APP_VERSION);
|
||||
|
||||
return Response.json(
|
||||
buildResponse(repository, branch, {
|
||||
latestVersion,
|
||||
latestRelease,
|
||||
status:
|
||||
comparison > 0
|
||||
? 'update-available'
|
||||
: comparison < 0
|
||||
? 'ahead-of-remote'
|
||||
: 'up-to-date',
|
||||
updateAvailable: comparison > 0,
|
||||
checkedRemotely: true,
|
||||
usedRemoteManifest: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const remotePackage = await fetchJson<{ version?: string }>(`${source.manifestUrl.replace(MANIFEST_PATH, 'package.json')}`);
|
||||
const latestVersion = remotePackage?.version?.trim();
|
||||
|
||||
if (latestVersion) {
|
||||
const comparison = compareVersions(latestVersion, APP_VERSION);
|
||||
|
||||
return Response.json(
|
||||
buildResponse(repository, branch, {
|
||||
latestVersion,
|
||||
latestRelease: getReleaseByVersion(latestVersion, LOCAL_RELEASE_MANIFEST),
|
||||
status:
|
||||
comparison > 0
|
||||
? 'update-available'
|
||||
: comparison < 0
|
||||
? 'ahead-of-remote'
|
||||
: 'up-to-date',
|
||||
updateAvailable: comparison > 0,
|
||||
checkedRemotely: true,
|
||||
usedRemoteManifest: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
buildResponse(repository, branch, {
|
||||
status: 'check-failed',
|
||||
error: '无法获取远程版本信息。',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
buildResponse(repository, branch, {
|
||||
status: 'check-failed',
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
deleteManagedAccount,
|
||||
getPublicAuthConfig,
|
||||
getServerSession,
|
||||
isSuperAdminSession,
|
||||
updateManagedAccount,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
async function requireManagedSuperAdmin(request: NextRequest) {
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
|
||||
}
|
||||
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode !== 'managed') {
|
||||
return { error: NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 }) };
|
||||
}
|
||||
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
const auth = await requireManagedSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountId } = await context.params;
|
||||
const body = await request.json();
|
||||
const account = await updateManagedAccount(accountId, body);
|
||||
return NextResponse.json({ account });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
const auth = await requireManagedSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountId } = await context.params;
|
||||
await deleteManagedAccount(accountId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,63 @@
|
||||
/**
|
||||
* Accounts API Route
|
||||
* Returns account list (names + roles, no passwords) for admin visibility
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
createManagedAccount,
|
||||
getPublicAuthConfig,
|
||||
getServerSession,
|
||||
isSuperAdminSession,
|
||||
listAccountInfo,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
interface AccountInfo {
|
||||
name: string;
|
||||
role: 'super_admin' | 'admin' | 'viewer';
|
||||
customPermissions?: string[];
|
||||
}
|
||||
|
||||
function getAccountList(): AccountInfo[] {
|
||||
const accounts: AccountInfo[] = [];
|
||||
|
||||
// Add admin from ADMIN_PASSWORD
|
||||
if (effectiveAdminPassword) {
|
||||
accounts.push({ name: '超级管理员', role: 'super_admin' });
|
||||
async function requireSuperAdmin(request: NextRequest) {
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
|
||||
}
|
||||
|
||||
// Add accounts from ACCOUNTS env var
|
||||
if (ACCOUNTS) {
|
||||
ACCOUNTS.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0)
|
||||
.forEach(entry => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length >= 2) {
|
||||
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, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) });
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
|
||||
}
|
||||
|
||||
return accounts;
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const accounts = getAccountList();
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
const accounts = await listAccountInfo();
|
||||
|
||||
return NextResponse.json({
|
||||
loginMode: config.loginMode,
|
||||
managed: config.loginMode === 'managed',
|
||||
accounts,
|
||||
hasAdminPassword: !!effectiveAdminPassword,
|
||||
hasAccounts: !!ACCOUNTS,
|
||||
totalCount: accounts.length,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode !== 'managed') {
|
||||
return NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const account = await createManagedAccount(body);
|
||||
return NextResponse.json({ account }, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-124
@@ -1,145 +1,55 @@
|
||||
/**
|
||||
* Auth API Route
|
||||
* Handles authentication with role-based accounts
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
authenticateLogin,
|
||||
createLoginResponse,
|
||||
getPublicAuthConfig,
|
||||
ManagedAuthStorageError,
|
||||
validatePremiumAccess,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || '';
|
||||
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false'; // default true
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || '';
|
||||
const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || '';
|
||||
const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '';
|
||||
|
||||
// Backward compat: ACCESS_PASSWORD acts as ADMIN_PASSWORD if ADMIN_PASSWORD is not set
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
interface AccountEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: 'super_admin' | 'admin' | 'viewer';
|
||||
customPermissions: string[];
|
||||
}
|
||||
|
||||
function parseAccounts(): AccountEntry[] {
|
||||
if (!ACCOUNTS) return [];
|
||||
|
||||
return ACCOUNTS.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0)
|
||||
.map(entry => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic profileId from password using SHA-256.
|
||||
* Uses a salt to avoid rainbow table attacks.
|
||||
*/
|
||||
async function generateProfileId(password: string): Promise<string> {
|
||||
const salt = 'kvideo-profile-salt-v1';
|
||||
const data = new TextEncoder().encode(password + salt);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hash));
|
||||
// Use first 8 bytes (16 hex chars) for a compact but unique ID
|
||||
return hashArray.slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS);
|
||||
|
||||
return NextResponse.json({
|
||||
hasAuth,
|
||||
hasPremiumAuth: !!PREMIUM_PASSWORD,
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
iptvSources: IPTV_SOURCES,
|
||||
mergeSources: MERGE_SOURCES,
|
||||
danmakuApiUrl: DANMAKU_API_URL,
|
||||
});
|
||||
try {
|
||||
return NextResponse.json(await getPublicAuthConfig());
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedAuthStorageError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Managed authentication storage is unavailable' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password, type } = await request.json();
|
||||
const body = await request.json();
|
||||
const { username, password, type } = body || {};
|
||||
|
||||
if (type === 'premium') {
|
||||
const valid = await validatePremiumAccess(request, { username, password });
|
||||
return NextResponse.json({ valid });
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Premium password check (separate from main auth)
|
||||
if (type === 'premium') {
|
||||
if (!PREMIUM_PASSWORD) {
|
||||
// No premium password configured = open access
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
if (password === PREMIUM_PASSWORD) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Also allow admin password to unlock premium
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Check ACCOUNTS super_admin/admin
|
||||
const accounts = parseAccounts();
|
||||
for (const account of accounts) {
|
||||
if (password === account.password && (account.role === 'super_admin' || account.role === 'admin')) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
}
|
||||
const session = await authenticateLogin({ username, password });
|
||||
if (!session) {
|
||||
return NextResponse.json({ valid: false });
|
||||
}
|
||||
|
||||
// 1. Check admin password
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
const profileId = await generateProfileId(password);
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: '管理员',
|
||||
role: 'super_admin',
|
||||
profileId,
|
||||
persistSession: PERSIST_SESSION,
|
||||
});
|
||||
return createLoginResponse(session, request);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedAuthStorageError) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, message: 'Managed authentication storage is unavailable' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check ACCOUNTS entries
|
||||
const accounts = parseAccounts();
|
||||
for (const account of accounts) {
|
||||
if (password === account.password) {
|
||||
const profileId = await generateProfileId(password);
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
profileId,
|
||||
persistSession: PERSIST_SESSION,
|
||||
customPermissions: account.customPermissions.length > 0 ? account.customPermissions : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No match
|
||||
return NextResponse.json({ valid: false });
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import {
|
||||
createSessionStatusResponse,
|
||||
logoutResponse,
|
||||
ManagedAuthStorageError,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
return await createSessionStatusResponse(request);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedAuthStorageError) {
|
||||
return Response.json(
|
||||
{ authenticated: false, error: 'Managed authentication storage is unavailable' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return logoutResponse(request);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildDoubanImageCandidates } from '@/lib/server/douban-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const REQUEST_HEADERS = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const imageUrl = searchParams.get('url');
|
||||
@@ -10,51 +18,43 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
},
|
||||
});
|
||||
let lastStatus = 502;
|
||||
let lastError = 'Error fetching image';
|
||||
|
||||
for (const candidate of buildDoubanImageCandidates(imageUrl)) {
|
||||
let imageResponse: Response;
|
||||
|
||||
try {
|
||||
imageResponse = await fetch(candidate, { headers: REQUEST_HEADERS });
|
||||
} catch {
|
||||
// 线路不可达,换下一个镜像
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: imageResponse.statusText },
|
||||
{ status: imageResponse.status }
|
||||
);
|
||||
lastStatus = imageResponse.status;
|
||||
lastError = imageResponse.statusText || 'Error fetching image';
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
|
||||
if (!imageResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image response has no body' },
|
||||
{ status: 500 }
|
||||
);
|
||||
lastStatus = 500;
|
||||
lastError = 'Image response has no body';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建响应头
|
||||
const headers = new Headers();
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
}
|
||||
|
||||
// 设置缓存头
|
||||
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
|
||||
|
||||
// 直接返回图片流
|
||||
// @ts-ignore
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching image' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: lastError }, { status: lastStatus });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -91,6 +92,18 @@ const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
if (!runtimeFeatures.iptvEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'IPTV relay is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const customUa = request.nextUrl.searchParams.get('ua');
|
||||
const customReferer = request.nextUrl.searchParams.get('referer');
|
||||
|
||||
+7
-43
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { probeSourceLatency } from '@/lib/api/source-latency';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -18,53 +19,16 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
const parsedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
return NextResponse.json({ error: 'Unsupported URL protocol' }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Use HEAD request for faster ping (less data transfer)
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
mode: 'no-cors', // Allow cross-origin requests
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
|
||||
return NextResponse.json({ latency, success: true });
|
||||
} catch (fetchError) {
|
||||
// If HEAD fails, try GET with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
return NextResponse.json({ latency, success: true });
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
// Still return latency even on error (timeout = slow)
|
||||
return NextResponse.json({ latency, success: false, timeout: true });
|
||||
}
|
||||
}
|
||||
const result = await probeSourceLatency(url);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Ping error:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -8,105 +8,145 @@ import { NextRequest } from 'next/server';
|
||||
import { getSourceById } from '@/lib/api/video-sources';
|
||||
import { getVideoDetail } from '@/lib/api/detail-api';
|
||||
import { fetchWithTimeout } from '@/lib/api/http-utils';
|
||||
import {
|
||||
extractResolutionHint,
|
||||
extractVariantPlaylistUrls,
|
||||
parseResolutionFromManifest,
|
||||
type ResolutionProbeLabel,
|
||||
} from '@/lib/player/resolution-probe-utils';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
interface ProbeRequest {
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
function getResolutionLabel(width: number, height: number): { label: string; color: string } {
|
||||
const h = Math.min(width, height); // height is the shorter side
|
||||
if (h >= 2160) return { label: '4K', color: 'bg-amber-500' };
|
||||
if (h >= 1440) return { label: '2K', color: 'bg-emerald-500' };
|
||||
if (h >= 1080) return { label: '1080P', color: 'bg-green-500' };
|
||||
if (h >= 720) return { label: '720P', color: 'bg-teal-500' };
|
||||
if (h >= 480) return { label: '480P', color: 'bg-sky-500' };
|
||||
if (h >= 360) return { label: '360P', color: 'bg-gray-500' };
|
||||
return { label: `${h}P`, color: 'bg-gray-500' };
|
||||
}
|
||||
|
||||
function parseResolutionFromM3u8(content: string): { width: number; height: number } | null {
|
||||
const resolutions: { width: number; height: number }[] = [];
|
||||
const regex = /RESOLUTION=(\d+)x(\d+)/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
resolutions.push({ width: parseInt(match[1]), height: parseInt(match[2]) });
|
||||
function isValidSourceConfig(value: unknown): value is VideoSource {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (resolutions.length === 0) return null;
|
||||
// Return highest resolution
|
||||
return resolutions.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
|
||||
|
||||
const source = value as Partial<VideoSource>;
|
||||
return typeof source.id === 'string' &&
|
||||
typeof source.name === 'string' &&
|
||||
typeof source.baseUrl === 'string' &&
|
||||
typeof source.searchPath === 'string' &&
|
||||
typeof source.detailPath === 'string';
|
||||
}
|
||||
|
||||
async function probeOne(video: ProbeRequest): Promise<{
|
||||
function buildSourceConfigMap(rawConfigs: unknown): Map<string, VideoSource> {
|
||||
const configs = new Map<string, VideoSource>();
|
||||
if (!Array.isArray(rawConfigs)) {
|
||||
return configs;
|
||||
}
|
||||
|
||||
for (const config of rawConfigs) {
|
||||
if (isValidSourceConfig(config)) {
|
||||
configs.set(config.id, config);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
async function fetchManifestText(url: string, timeoutMs: number): Promise<string> {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, timeoutMs);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function probeManifestResolution(
|
||||
targetUrl: string,
|
||||
m3u8Content: string,
|
||||
detailHint: ResolutionProbeLabel | null
|
||||
): Promise<{ resolution: ResolutionProbeLabel | null; origin: 'manifest' | 'hint' }> {
|
||||
const directResolution = parseResolutionFromManifest(m3u8Content, targetUrl);
|
||||
if (directResolution) {
|
||||
return { resolution: directResolution, origin: 'manifest' };
|
||||
}
|
||||
|
||||
const variantUrls = extractVariantPlaylistUrls(m3u8Content, targetUrl).slice(0, 4);
|
||||
for (const variantUrl of variantUrls) {
|
||||
const variantHint = extractResolutionHint(variantUrl);
|
||||
if (variantHint?.width || variantHint?.height) {
|
||||
return { resolution: variantHint, origin: 'manifest' };
|
||||
}
|
||||
|
||||
try {
|
||||
const variantContent = await fetchManifestText(variantUrl, 6000);
|
||||
const variantResolution = parseResolutionFromManifest(variantContent, variantUrl);
|
||||
if (variantResolution) {
|
||||
return { resolution: variantResolution, origin: 'manifest' };
|
||||
}
|
||||
} catch {
|
||||
// Continue trying the next variant.
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackHint = extractResolutionHint(targetUrl, m3u8Content) || detailHint;
|
||||
return {
|
||||
resolution: fallbackHint,
|
||||
origin: fallbackHint ? 'hint' : 'manifest',
|
||||
};
|
||||
}
|
||||
|
||||
async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoSource>): Promise<{
|
||||
id: string | number;
|
||||
source: string;
|
||||
resolution: { width: number; height: number; label: string; color: string } | null;
|
||||
episodeIndex?: number;
|
||||
resolution: ResolutionProbeLabel | null;
|
||||
resolutionOrigin: 'manifest' | 'hint';
|
||||
}> {
|
||||
try {
|
||||
const sourceConfig = getSourceById(video.source);
|
||||
if (!sourceConfig) return { id: video.id, source: video.source, resolution: null };
|
||||
const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source);
|
||||
if (!sourceConfig) {
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
|
||||
}
|
||||
|
||||
// 1. Get detail to find first episode URL
|
||||
const detail = await getVideoDetail(video.id, sourceConfig);
|
||||
if (!detail.episodes || detail.episodes.length === 0) {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
|
||||
}
|
||||
|
||||
const firstUrl = detail.episodes[0].url;
|
||||
if (!firstUrl) return { id: video.id, source: video.source, resolution: null };
|
||||
const episodeIndex = typeof video.episodeIndex === 'number'
|
||||
? Math.min(Math.max(video.episodeIndex, 0), detail.episodes.length - 1)
|
||||
: 0;
|
||||
const targetUrl = detail.episodes[episodeIndex]?.url || detail.episodes[0]?.url;
|
||||
if (!targetUrl) {
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
|
||||
}
|
||||
|
||||
const detailHint = extractResolutionHint(detail.vod_remarks, targetUrl);
|
||||
|
||||
// 2. Fetch the m3u8 manifest
|
||||
let m3u8Content: string;
|
||||
try {
|
||||
const res = await fetchWithTimeout(firstUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, 8000);
|
||||
m3u8Content = await res.text();
|
||||
m3u8Content = await fetchManifestText(targetUrl, 8000);
|
||||
} catch {
|
||||
// Try with proxy
|
||||
try {
|
||||
const proxyUrl = new URL('/api/proxy', 'http://localhost');
|
||||
proxyUrl.searchParams.set('url', firstUrl);
|
||||
// Can't call our own proxy from edge easily, so just return null
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
} catch {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
}
|
||||
return {
|
||||
id: video.id,
|
||||
source: video.source,
|
||||
episodeIndex,
|
||||
resolution: detailHint,
|
||||
resolutionOrigin: detailHint ? 'hint' : 'manifest',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Parse RESOLUTION from manifest
|
||||
const res = parseResolutionFromM3u8(m3u8Content);
|
||||
if (!res) {
|
||||
// Simple playlist without RESOLUTION tags — try to follow sub-playlist
|
||||
// Look for a URL in the content that might be a sub-playlist
|
||||
const lines = m3u8Content.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') && (trimmed.endsWith('.m3u8') || trimmed.includes('.m3u8?'))) {
|
||||
try {
|
||||
const subUrl = trimmed.startsWith('http') ? trimmed : new URL(trimmed, firstUrl).toString();
|
||||
const subRes = await fetchWithTimeout(subUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, 6000);
|
||||
const subContent = await subRes.text();
|
||||
const subResolution = parseResolutionFromM3u8(subContent);
|
||||
if (subResolution) {
|
||||
const labelInfo = getResolutionLabel(subResolution.width, subResolution.height);
|
||||
return { id: video.id, source: video.source, resolution: { ...subResolution, ...labelInfo } };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
break; // Only try the first sub-playlist
|
||||
}
|
||||
}
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
}
|
||||
|
||||
const labelInfo = getResolutionLabel(res.width, res.height);
|
||||
return { id: video.id, source: video.source, resolution: { ...res, ...labelInfo } };
|
||||
const probed = await probeManifestResolution(targetUrl, m3u8Content, detailHint);
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: probed.resolution, resolutionOrigin: probed.origin };
|
||||
} catch {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return {
|
||||
id: video.id,
|
||||
source: video.source,
|
||||
episodeIndex: video.episodeIndex,
|
||||
resolution: null,
|
||||
resolutionOrigin: 'manifest',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +154,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const videos: ProbeRequest[] = body.videos;
|
||||
const sourceConfigs = buildSourceConfigMap(body.sourceConfigs);
|
||||
|
||||
if (!Array.isArray(videos) || videos.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Missing videos array' }), {
|
||||
@@ -122,8 +163,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Limit batch size
|
||||
const batch = videos.slice(0, 30);
|
||||
const batch = videos.slice(0, 100);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
@@ -136,11 +176,11 @@ export async function POST(request: NextRequest) {
|
||||
while (index < batch.length) {
|
||||
const current = batch[index++];
|
||||
try {
|
||||
const result = await probeOne(current);
|
||||
const result = await probeOne(current, sourceConfigs);
|
||||
const line = `data: ${JSON.stringify(result)}\n\n`;
|
||||
controller.enqueue(encoder.encode(line));
|
||||
} catch {
|
||||
const fallback = { id: current.id, source: current.source, resolution: null };
|
||||
const fallback = { id: current.id, source: current.source, resolution: null, resolutionOrigin: 'manifest' };
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`));
|
||||
}
|
||||
}
|
||||
@@ -160,7 +200,7 @@ export async function POST(request: NextRequest) {
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Internal error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processM3u8Content } from '@/lib/utils/proxy-utils';
|
||||
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -9,6 +10,18 @@ export const runtime = 'edge';
|
||||
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
if (!runtimeFeatures.mediaProxyEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'External media proxy is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
|
||||
@@ -5,23 +5,36 @@
|
||||
* so they persist across browsers, devices, and PWA installs.
|
||||
*/
|
||||
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticationRequiredResponse } from '@/lib/server/api-responses';
|
||||
import { getServerSession } from '@/lib/server/auth';
|
||||
import { getRedisClient } from '@/lib/server/redis';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const redis = Redis.fromEnv();
|
||||
|
||||
function redisKey(profileId: string): string {
|
||||
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return `user:config:${safe}`;
|
||||
}
|
||||
|
||||
function syncUnavailableResponse() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server-side sync is not configured on this deployment' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
return authenticationRequiredResponse();
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return syncUnavailableResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -37,10 +50,16 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
return authenticationRequiredResponse();
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return syncUnavailableResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -48,7 +67,7 @@ export async function POST(request: NextRequest) {
|
||||
const key = redisKey(profileId);
|
||||
|
||||
// Merge with existing data if present
|
||||
const existing = (await redis.get(key)) as Record<string, any> | null;
|
||||
const existing = (await redis.get(key)) as Record<string, unknown> | null;
|
||||
const merged = { ...(existing || {}), ...body, updatedAt: Date.now() };
|
||||
|
||||
await redis.set(key, merged);
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticationRequiredResponse } from '@/lib/server/api-responses';
|
||||
import { getServerSession } from '@/lib/server/auth';
|
||||
import { getRedisClient } from '@/lib/server/redis';
|
||||
|
||||
// 确保这行代码在整个文件中只出现一次
|
||||
export const runtime = 'edge';
|
||||
|
||||
const redis = Redis.fromEnv();
|
||||
function syncUnavailableResponse() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server-side sync is not configured on this deployment' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
return authenticationRequiredResponse();
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return syncUnavailableResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -26,10 +39,16 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
return authenticationRequiredResponse();
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return syncUnavailableResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import {
|
||||
FavoritesPageContent,
|
||||
FavoritesPageFallback,
|
||||
} from '@/components/favorites/FavoritesPageContent';
|
||||
|
||||
export default function Favorites() {
|
||||
return (
|
||||
<Suspense fallback={<FavoritesPageFallback />}>
|
||||
<FavoritesPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+1
-8
@@ -1,14 +1,7 @@
|
||||
import { VideoTogetherScript } from '@/components/VideoTogetherScript';
|
||||
|
||||
export default function IPTVLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<VideoTogetherScript />
|
||||
</>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
+28
-7
@@ -11,11 +11,13 @@ import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
|
||||
import { IPTVPlayer } from '@/components/iptv/IPTVPlayer';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { hasPermission, getSession } from '@/lib/store/auth-store';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import Link from 'next/link';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
export default function IPTVPage() {
|
||||
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
|
||||
const { iptvEnabled, restrictionSummary } = useRuntimeFeatures();
|
||||
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading } = useIPTVStore();
|
||||
const [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
|
||||
@@ -45,6 +47,17 @@ export default function IPTVPage() {
|
||||
[visibleSources, cachedChannelsBySource]
|
||||
);
|
||||
|
||||
// Auto-refresh on first load if we have sources but no cached channels
|
||||
useEffect(() => {
|
||||
if (!iptvEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
|
||||
refreshSources();
|
||||
}
|
||||
}, [iptvEnabled, sources.length, cachedChannels.length, isLoading, refreshSources]);
|
||||
|
||||
// If auth is configured and user doesn't have iptv_access, show access denied
|
||||
if (!canAccessIPTV && getSession()) {
|
||||
return (
|
||||
@@ -59,12 +72,20 @@ export default function IPTVPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-refresh on first load if we have sources but no cached channels
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
|
||||
refreshSources();
|
||||
}
|
||||
}, [sources.length, cachedChannels.length, isLoading, refreshSources]);
|
||||
if (!iptvEnabled) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)]">
|
||||
<div className="max-w-xl mx-auto px-6 py-8 text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)]">
|
||||
<Icons.TV size={48} className="mx-auto mb-4 text-[var(--text-color-secondary)] opacity-40" />
|
||||
<p className="text-[var(--text-color)] font-medium mb-2">当前部署已禁用 IPTV</p>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
{restrictionSummary}
|
||||
</p>
|
||||
<Link href="/" className="text-sm text-[var(--accent-color)] hover:underline">返回首页</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
|
||||
|
||||
+62
-36
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { AutoSync } from '@/components/AutoSync'; // <-- 引入了自动同步组件
|
||||
import { SiteIconProvider } from '@/components/SiteIconProvider';
|
||||
import { TVProvider } from "@/lib/contexts/TVContext";
|
||||
import { TVNavigationInitializer } from "@/components/TVNavigationInitializer";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
@@ -14,9 +14,16 @@ import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { ScrollPositionManager } from "@/components/ScrollPositionManager";
|
||||
import { LocaleProvider } from "@/components/LocaleProvider";
|
||||
import { RuntimeFeaturesProvider } from "@/components/RuntimeFeaturesProvider";
|
||||
import { VideoTogetherController } from '@/components/VideoTogetherController';
|
||||
import { shouldEnableVercelAnalytics } from '@/lib/config/deployment';
|
||||
import { getRuntimeFeatures } from "@/lib/server/runtime-features";
|
||||
import { resolveSiteIconSrc } from '@/lib/server/site-icon';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DEFAULT_VIDEOTOGETHER_SCRIPT_URL =
|
||||
'https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js';
|
||||
|
||||
// Server Component specifically for reading env/file (async for best practices)
|
||||
async function AdKeywordsWrapper() {
|
||||
@@ -57,29 +64,31 @@ async function AdKeywordsWrapper() {
|
||||
return <AdKeywordsInjector keywords={keywords} />;
|
||||
}
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const siteIconSrc = await resolveSiteIconSrc();
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
return {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
icons: {
|
||||
icon: siteIconSrc,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
icons: {
|
||||
icon: '/icon.png',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const siteIconSrc = await resolveSiteIconSrc();
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
const videoTogetherScriptUrl =
|
||||
process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL;
|
||||
const videoTogetherSettingUrl = process.env.VIDEOTOGETHER_SETTING_URL?.trim();
|
||||
const videoTogetherEnvEnabled = process.env.VIDEOTOGETHER_ENABLED !== 'false';
|
||||
const vercelAnalyticsEnabled = shouldEnableVercelAnalytics();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
@@ -89,33 +98,50 @@ export default function RootLayout({
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KVideo" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<link rel="apple-touch-icon" href={siteIconSrc} />
|
||||
{/* Theme Color (for browser address bar) */}
|
||||
<meta name="theme-color" content="#000000" />
|
||||
{/* Mobile viewport */}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className="antialiased"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider>
|
||||
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
|
||||
<AutoSync />
|
||||
<LocaleProvider />
|
||||
<SiteIconProvider iconSrc={siteIconSrc}>
|
||||
<ThemeProvider>
|
||||
<RuntimeFeaturesProvider initialFeatures={runtimeFeatures}>
|
||||
<VideoTogetherController
|
||||
envEnabled={videoTogetherEnvEnabled}
|
||||
scriptUrl={videoTogetherScriptUrl}
|
||||
settingUrl={videoTogetherSettingUrl}
|
||||
/>
|
||||
<LocaleProvider />
|
||||
|
||||
<TVProvider>
|
||||
<TVNavigationInitializer />
|
||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||
<AdKeywordsWrapper />
|
||||
{children}
|
||||
<BackToTop />
|
||||
<ScrollPositionManager />
|
||||
</PasswordGate>
|
||||
</TVProvider>
|
||||
<Analytics />
|
||||
<ServiceWorkerRegister />
|
||||
</ThemeProvider>
|
||||
<TVProvider>
|
||||
<TVNavigationInitializer />
|
||||
<PasswordGate hasAuth={!!(
|
||||
process.env.ADMIN_PASSWORD ||
|
||||
process.env.ACCOUNTS ||
|
||||
process.env.ACCESS_PASSWORD ||
|
||||
(
|
||||
process.env.AUTH_SECRET &&
|
||||
process.env.UPSTASH_REDIS_REST_URL &&
|
||||
process.env.UPSTASH_REDIS_REST_TOKEN
|
||||
)
|
||||
)}>
|
||||
<AutoSync />
|
||||
<AdKeywordsWrapper />
|
||||
{children}
|
||||
<BackToTop />
|
||||
<ScrollPositionManager />
|
||||
</PasswordGate>
|
||||
</TVProvider>
|
||||
{vercelAnalyticsEnabled ? <Analytics /> : null}
|
||||
<ServiceWorkerRegister />
|
||||
</RuntimeFeaturesProvider>
|
||||
</ThemeProvider>
|
||||
</SiteIconProvider>
|
||||
|
||||
{/* ARIA Live Region for Screen Reader Announcements */}
|
||||
<div
|
||||
|
||||
+3
-5
@@ -4,7 +4,6 @@ import { Suspense, useMemo } from 'react';
|
||||
import { SearchForm } from '@/components/search/SearchForm';
|
||||
import { NoResults } from '@/components/search/NoResults';
|
||||
import { PopularFeatures } from '@/components/home/PopularFeatures';
|
||||
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
import { Navbar } from '@/components/layout/Navbar';
|
||||
import { SearchResults } from '@/components/home/SearchResults';
|
||||
@@ -27,7 +26,9 @@ function HomePage() {
|
||||
|
||||
// Real-time latency pinging
|
||||
const sourceUrls = useMemo(() =>
|
||||
availableSources.map(s => ({ id: s.id, baseUrl: s.id })), // Using id as baseUrl if not available elsewhere
|
||||
availableSources.flatMap((source) =>
|
||||
source.baseUrl ? [{ id: source.id, baseUrl: source.baseUrl }] : []
|
||||
),
|
||||
[availableSources]
|
||||
);
|
||||
|
||||
@@ -85,9 +86,6 @@ function HomePage() {
|
||||
|
||||
{/* Favorites Sidebar - Left */}
|
||||
<FavoritesSidebar />
|
||||
|
||||
{/* Watch History Sidebar - Right */}
|
||||
<WatchHistorySidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Metadata } from 'next';
|
||||
import { VideoTogetherScript } from '@/components/VideoTogetherScript';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
referrer: 'no-referrer',
|
||||
@@ -10,10 +9,5 @@ export default function PlayerLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<VideoTogetherScript />
|
||||
</>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
+138
-105
@@ -10,6 +10,7 @@ import { SourceInfo } from '@/components/player/EpisodeList';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution';
|
||||
import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe';
|
||||
import { setCachedResolution } from '@/lib/player/resolution-cache';
|
||||
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
@@ -44,6 +45,7 @@ function PlayerContent() {
|
||||
// Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key)
|
||||
const groupedSourcesParam = searchParams.get('groupedSources');
|
||||
const gsKey = searchParams.get('gs');
|
||||
const missingRequiredParams = !videoId || !source;
|
||||
|
||||
// Track settings - use mode-specific store
|
||||
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
@@ -64,7 +66,7 @@ function PlayerContent() {
|
||||
// Sync with store changes if any (though usually it's one-way from UI to store)
|
||||
useEffect(() => {
|
||||
setIsReversed(modeStore.getSettings().episodeReverseOrder);
|
||||
}, []);
|
||||
}, [modeStore]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode);
|
||||
@@ -86,17 +88,47 @@ function PlayerContent() {
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}, []); // Run once on mount
|
||||
}, [groupedSourcesParam, gsKey, router, searchParams]);
|
||||
|
||||
// Redirect if no video ID or source
|
||||
if (!videoId || !source) {
|
||||
router.push('/');
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (missingRequiredParams) {
|
||||
router.push('/');
|
||||
}
|
||||
}, [missingRequiredParams, router]);
|
||||
|
||||
// Handle auto-fallback when current source is unavailable (defined later, uses ref)
|
||||
const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined);
|
||||
const pendingFallbackRef = useRef(false);
|
||||
const [pendingFallback, setPendingFallback] = useState(false);
|
||||
const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
|
||||
const groupedSourcesRef = useRef<SourceInfo[]>([]);
|
||||
|
||||
const handleSourceUnavailable = useCallback(() => {
|
||||
const groupedSources = groupedSourcesRef.current;
|
||||
const alternatives = groupedSources.filter((item) => item.source !== source);
|
||||
if (alternatives.length === 0) {
|
||||
setPendingFallback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingFallback(false);
|
||||
const best = [...alternatives].sort((left, right) => {
|
||||
const latA = left.latency ?? Infinity;
|
||||
const latB = right.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
})[0];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(best.id));
|
||||
params.set('source', best.source);
|
||||
params.set('title', title || '');
|
||||
if (episodeParam) params.set('episode', episodeParam);
|
||||
if (gsKey) {
|
||||
params.set('gs', gsKey);
|
||||
} else if (groupedSources.length > 1) {
|
||||
const newKey = storeGroupedSources(groupedSources);
|
||||
if (newKey) params.set('gs', newKey);
|
||||
}
|
||||
if (isPremium) params.set('premium', '1');
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
}, [episodeParam, gsKey, isPremium, router, source, title]);
|
||||
|
||||
const {
|
||||
videoData,
|
||||
@@ -108,17 +140,11 @@ function PlayerContent() {
|
||||
setPlayUrl,
|
||||
setVideoError,
|
||||
fetchVideoDetails,
|
||||
} = useVideoPlayer(videoId, source, episodeParam, isReversed, useCallback(() => {
|
||||
sourceUnavailableRef.current?.();
|
||||
}, []));
|
||||
|
||||
// Parse grouped sources if available
|
||||
const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
|
||||
} = useVideoPlayer(videoId, source, episodeParam, isReversed, handleSourceUnavailable);
|
||||
|
||||
const groupedSources = useMemo<SourceInfo[]>(() => {
|
||||
let sources: SourceInfo[] = [];
|
||||
|
||||
// Try sessionStorage cache first (new short URL), then fall back to URL param (legacy)
|
||||
if (gsKey) {
|
||||
const cached = retrieveGroupedSources(gsKey);
|
||||
if (cached) sources = cached;
|
||||
@@ -130,72 +156,41 @@ function PlayerContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge in discovered sources (from background search)
|
||||
if (discoveredSources.length > 0) {
|
||||
for (const ds of discoveredSources) {
|
||||
if (!sources.find(s => s.source === ds.source)) {
|
||||
if (!sources.find((item) => item.source === ds.source)) {
|
||||
sources.push(ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure the current source is in the list
|
||||
if (source && !sources.find(s => s.source === source)) {
|
||||
if (source && !sources.find((item) => item.source === source)) {
|
||||
sources.unshift({
|
||||
id: videoId || '',
|
||||
source: source,
|
||||
source,
|
||||
sourceName: getSourceName(source),
|
||||
pic: videoData?.vod_pic
|
||||
pic: videoData?.vod_pic,
|
||||
});
|
||||
}
|
||||
|
||||
// Use current video's poster as fallback pic for sources that don't have one
|
||||
const fallbackPic = videoData?.vod_pic;
|
||||
if (fallbackPic) {
|
||||
sources = sources.map(s => s.pic ? s : { ...s, pic: fallbackPic });
|
||||
sources = sources.map((item) => item.pic ? item : { ...item, pic: fallbackPic });
|
||||
}
|
||||
|
||||
return sources;
|
||||
}, [gsKey, groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]);
|
||||
}, [discoveredSources, groupedSourcesParam, gsKey, source, videoData?.vod_pic, videoId]);
|
||||
|
||||
// Wire up the source unavailable handler now that groupedSources is defined
|
||||
sourceUnavailableRef.current = () => {
|
||||
const alternatives = groupedSources.filter(s => s.source !== source);
|
||||
if (alternatives.length === 0) {
|
||||
// No alternatives yet — mark pending so we retry when discovered sources arrive
|
||||
pendingFallbackRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFallbackRef.current = false;
|
||||
const best = [...alternatives].sort((a, b) => {
|
||||
const latA = a.latency ?? Infinity;
|
||||
const latB = b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
})[0];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(best.id));
|
||||
params.set('source', best.source);
|
||||
params.set('title', title || '');
|
||||
if (episodeParam) params.set('episode', episodeParam);
|
||||
// Use short gs key for grouped sources
|
||||
if (gsKey) {
|
||||
params.set('gs', gsKey);
|
||||
} else if (groupedSources.length > 1) {
|
||||
const newKey = storeGroupedSources(groupedSources);
|
||||
if (newKey) params.set('gs', newKey);
|
||||
}
|
||||
if (isPremium) params.set('premium', '1');
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
useEffect(() => {
|
||||
groupedSourcesRef.current = groupedSources;
|
||||
}, [groupedSources]);
|
||||
|
||||
// Retry pending fallback when discovered sources arrive
|
||||
useEffect(() => {
|
||||
if (pendingFallbackRef.current && discoveredSources.length > 0) {
|
||||
sourceUnavailableRef.current?.();
|
||||
if (pendingFallback && discoveredSources.length > 0) {
|
||||
handleSourceUnavailable();
|
||||
}
|
||||
}, [discoveredSources]);
|
||||
}, [discoveredSources, handleSourceUnavailable, pendingFallback]);
|
||||
|
||||
// Background fetch alternative sources when none provided or when existing ones lack full info
|
||||
const fetchedSourcesRef = useRef(false);
|
||||
@@ -211,7 +206,7 @@ function PlayerContent() {
|
||||
try { existingSources = JSON.parse(groupedSourcesParam); } catch {}
|
||||
}
|
||||
// Always fetch alternatives if there's a pending fallback (source unavailable)
|
||||
const hasFullInfo = !pendingFallbackRef.current && existingSources.length > 1 &&
|
||||
const hasFullInfo = !pendingFallback && existingSources.length > 1 &&
|
||||
existingSources.every(s => s.pic || s.latency !== undefined);
|
||||
if (hasFullInfo) return;
|
||||
|
||||
@@ -255,7 +250,16 @@ function PlayerContent() {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.type === 'videos' && data.videos) {
|
||||
// Find exact or close title match
|
||||
const match = data.videos.find((v: any) =>
|
||||
const match = data.videos.find((v: {
|
||||
vod_name?: string;
|
||||
vod_id: string | number;
|
||||
source: string;
|
||||
sourceDisplayName?: string;
|
||||
latency?: number;
|
||||
vod_pic?: string;
|
||||
type_name?: string;
|
||||
vod_remarks?: string;
|
||||
}) =>
|
||||
v.vod_name?.toLowerCase().trim() === normalizedTitle
|
||||
);
|
||||
if (match) {
|
||||
@@ -281,24 +285,43 @@ function PlayerContent() {
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [title, source, gsKey, groupedSourcesParam, isPremium]);
|
||||
}, [groupedSourcesParam, gsKey, isPremium, pendingFallback, source, title]);
|
||||
|
||||
// Track current source for switching
|
||||
const [currentSourceId, setCurrentSourceId] = useState(source);
|
||||
const playerTimeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSourceId(source);
|
||||
}, [source]);
|
||||
|
||||
// Track detected video resolution from the player
|
||||
const [detectedResolution, setDetectedResolution] = useState<VideoResolutionInfo | null>(null);
|
||||
|
||||
// Probe resolution for all grouped sources (not just the playing one)
|
||||
const probeList = useMemo(() => {
|
||||
return groupedSources.map(s => ({ id: s.id, source: s.source }));
|
||||
}, [groupedSources]);
|
||||
return groupedSources.map((item) => ({
|
||||
id: item.id,
|
||||
source: item.source,
|
||||
episodeIndex: currentEpisode,
|
||||
}));
|
||||
}, [groupedSources, currentEpisode]);
|
||||
const { resolutions: sourceResolutions } = useResolutionProbe(probeList);
|
||||
|
||||
const handleResolutionDetected = useCallback((info: VideoResolutionInfo) => {
|
||||
setDetectedResolution(info);
|
||||
if (videoId && source) {
|
||||
setCachedResolution(source, videoId, {
|
||||
...info,
|
||||
origin: 'played',
|
||||
episodeIndex: currentEpisode,
|
||||
});
|
||||
}
|
||||
}, [currentEpisode, source, videoId]);
|
||||
|
||||
// Add initial history entry when video data is loaded
|
||||
useEffect(() => {
|
||||
if (videoData && playUrl && videoId) {
|
||||
if (videoData && playUrl && videoId && source) {
|
||||
// Map episodes to include index
|
||||
const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({
|
||||
name: ep.name || `第${idx + 1}集`,
|
||||
@@ -321,7 +344,7 @@ function PlayerContent() {
|
||||
}
|
||||
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
|
||||
|
||||
const handleEpisodeClick = useCallback((episode: any, index: number) => {
|
||||
const handleEpisodeClick = useCallback((episode: { url: string }, index: number) => {
|
||||
setCurrentEpisode(index);
|
||||
setPlayUrl(episode.url);
|
||||
setVideoError('');
|
||||
@@ -359,7 +382,7 @@ function PlayerContent() {
|
||||
if (nextEpisode) {
|
||||
handleEpisodeClick(nextEpisode, nextIndex); // handleEpisodeClick relies on state setters, which are stable
|
||||
}
|
||||
}, [videoData, currentEpisode, isReversed, router, searchParams]); // handleEpisodeClick is not memoized, but uses stable hooks setters. wait, handleEpisodeClick is inline too!
|
||||
}, [currentEpisode, handleEpisodeClick, isReversed, videoData]);
|
||||
|
||||
const effectivePlayerViewportMode = useMemo<PlayerViewportMode>(() => {
|
||||
const manualIndex = PLAYER_VIEWPORT_MODE_ORDER.indexOf(playerViewportMode);
|
||||
@@ -374,12 +397,16 @@ function PlayerContent() {
|
||||
? 'xl:grid-cols-[minmax(0,1.65fr)_minmax(300px,0.72fr)]'
|
||||
: 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]';
|
||||
|
||||
if (missingRequiredParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)]">
|
||||
{/* Glass Navbar */}
|
||||
<PlayerNavbar isPremium={isPremium} />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-2">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent mb-4"></div>
|
||||
@@ -392,44 +419,49 @@ function PlayerContent() {
|
||||
onRetry={fetchVideoDetails}
|
||||
/>
|
||||
) : (
|
||||
<div className={`grid gap-6 lg:grid-cols-3 ${playerGridClass}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Viewport controls span full content width so player + sidebar tops align */}
|
||||
<div className="hidden lg:flex items-center justify-between gap-4 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--text-color)]">
|
||||
播放窗口大小
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
右侧源列表或选集折叠后,会自动提升到更宽的布局
|
||||
{effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`}
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedControl<PlayerViewportMode>
|
||||
options={[
|
||||
{ label: '标准', value: 'standard' },
|
||||
{ label: '宽屏', value: 'wide' },
|
||||
{ label: '影院', value: 'cinema' },
|
||||
]}
|
||||
value={playerViewportMode}
|
||||
onChange={setPlayerViewportMode}
|
||||
className="min-w-[240px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-6 lg:grid-cols-3 lg:items-start ${playerGridClass}`}>
|
||||
{/* Video Player Section */}
|
||||
<div className="lg:col-span-2 xl:col-span-1 space-y-6">
|
||||
<div className="hidden lg:flex items-center justify-between gap-4 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--text-color)]">
|
||||
播放窗口大小
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
右侧源列表或选集折叠后,会自动提升到更宽的布局
|
||||
{effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`}
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedControl<PlayerViewportMode>
|
||||
options={[
|
||||
{ label: '标准', value: 'standard' },
|
||||
{ label: '宽屏', value: 'wide' },
|
||||
{ label: '影院', value: 'cinema' },
|
||||
]}
|
||||
value={playerViewportMode}
|
||||
onChange={setPlayerViewportMode}
|
||||
className="min-w-[240px]"
|
||||
<div className="sm:mx-0">
|
||||
<VideoPlayer
|
||||
playUrl={playUrl}
|
||||
videoId={videoId || undefined}
|
||||
currentEpisode={currentEpisode}
|
||||
onBack={() => router.back()}
|
||||
totalEpisodes={videoData?.episodes?.length || 0}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
isReversed={isReversed}
|
||||
isPremium={isPremium}
|
||||
videoTitle={videoData?.vod_name || title || ''}
|
||||
episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
|
||||
externalTimeRef={playerTimeRef}
|
||||
onResolutionDetected={handleResolutionDetected}
|
||||
/>
|
||||
</div>
|
||||
<VideoPlayer
|
||||
playUrl={playUrl}
|
||||
videoId={videoId || undefined}
|
||||
currentEpisode={currentEpisode}
|
||||
onBack={() => router.back()}
|
||||
totalEpisodes={videoData?.episodes?.length || 0}
|
||||
onNextEpisode={handleNextEpisode}
|
||||
isReversed={isReversed}
|
||||
isPremium={isPremium}
|
||||
videoTitle={videoData?.vod_name || title || ''}
|
||||
episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
|
||||
externalTimeRef={playerTimeRef}
|
||||
onResolutionDetected={setDetectedResolution}
|
||||
/>
|
||||
<div className="hidden lg:block">
|
||||
<VideoMetadata
|
||||
videoData={videoData}
|
||||
@@ -461,9 +493,9 @@ function PlayerContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar with sticky wrapper */}
|
||||
{/* Sidebar with sticky wrapper — top offset matches navbar height for alignment */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="lg:sticky lg:top-32 space-y-6">
|
||||
<div className="lg:sticky lg:top-28 space-y-6">
|
||||
{/* Mobile Tabs */}
|
||||
<SegmentedControl
|
||||
options={[
|
||||
@@ -530,6 +562,7 @@ function PlayerContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import {
|
||||
FavoritesPageContent,
|
||||
FavoritesPageFallback,
|
||||
} from '@/components/favorites/FavoritesPageContent';
|
||||
|
||||
export default function PremiumFavorites() {
|
||||
return (
|
||||
<Suspense fallback={<FavoritesPageFallback isPremium />}>
|
||||
<FavoritesPageContent isPremium />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -36,26 +36,34 @@ export function usePremiumSettingsPage() {
|
||||
const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sources come from main settings store
|
||||
const settings = settingsStore.getSettings();
|
||||
setPremiumSources(settings.premiumSources || []);
|
||||
setLocale(settings.locale);
|
||||
const syncFromStores = () => {
|
||||
const settings = settingsStore.getSettings();
|
||||
const modeSettings = premiumModeSettingsStore.getSettings();
|
||||
|
||||
// Mode-specific settings come from premium mode settings store
|
||||
const modeSettings = premiumModeSettingsStore.getSettings();
|
||||
setRealtimeLatency(modeSettings.realtimeLatency);
|
||||
setSearchDisplayMode(modeSettings.searchDisplayMode);
|
||||
setFullscreenType(modeSettings.fullscreenType);
|
||||
setProxyMode(modeSettings.proxyMode);
|
||||
setSeekStepSeconds(modeSettings.seekStepSeconds);
|
||||
setRememberScrollPosition(modeSettings.rememberScrollPosition);
|
||||
setDanmakuApiUrl(modeSettings.danmakuApiUrl);
|
||||
setDanmakuOpacity(modeSettings.danmakuOpacity);
|
||||
setDanmakuFontSize(modeSettings.danmakuFontSize);
|
||||
setDanmakuDisplayArea(modeSettings.danmakuDisplayArea);
|
||||
setPremiumSources(settings.premiumSources || []);
|
||||
setLocale(settings.locale);
|
||||
setBlockedCategories(settings.blockedCategories || []);
|
||||
|
||||
// blockedCategories is global
|
||||
setBlockedCategories(settings.blockedCategories || []);
|
||||
setRealtimeLatency(modeSettings.realtimeLatency);
|
||||
setSearchDisplayMode(modeSettings.searchDisplayMode);
|
||||
setFullscreenType(modeSettings.fullscreenType);
|
||||
setProxyMode(modeSettings.proxyMode);
|
||||
setSeekStepSeconds(modeSettings.seekStepSeconds);
|
||||
setRememberScrollPosition(modeSettings.rememberScrollPosition);
|
||||
setDanmakuApiUrl(modeSettings.danmakuApiUrl);
|
||||
setDanmakuOpacity(modeSettings.danmakuOpacity);
|
||||
setDanmakuFontSize(modeSettings.danmakuFontSize);
|
||||
setDanmakuDisplayArea(modeSettings.danmakuDisplayArea);
|
||||
};
|
||||
|
||||
syncFromStores();
|
||||
const unsubscribeSettings = settingsStore.subscribe(syncFromStores);
|
||||
const unsubscribePremiumSettings = premiumModeSettingsStore.subscribe(syncFromStores);
|
||||
|
||||
return () => {
|
||||
unsubscribeSettings();
|
||||
unsubscribePremiumSettings();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Source management (uses main settingsStore) ---
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings';
|
||||
import { DisplaySettings } from '@/components/settings/DisplaySettings';
|
||||
import { PlayerSettings } from '@/components/settings/PlayerSettings';
|
||||
import { AppVersionSettings } from '@/components/settings/AppVersionSettings';
|
||||
import { AccountSettings } from '@/components/settings/AccountSettings';
|
||||
import { AdminGate } from '@/components/AdminGate';
|
||||
import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage';
|
||||
import Link from 'next/link';
|
||||
@@ -75,6 +77,11 @@ export default function PremiumSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppVersionSettings />
|
||||
|
||||
{/* Account management and logout must remain available in premium mode. */}
|
||||
<AccountSettings />
|
||||
|
||||
{/* Player Settings */}
|
||||
<PlayerSettings
|
||||
fullscreenType={fullscreenType}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useSettingsPage() {
|
||||
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
|
||||
const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
|
||||
const [locale, setLocale] = useState<LocaleOption>('zh-CN');
|
||||
const [videoTogetherEnabled, setVideoTogetherEnabled] = useState(false);
|
||||
|
||||
// Danmaku settings
|
||||
const [danmakuApiUrl, setDanmakuApiUrl] = useState('');
|
||||
@@ -47,22 +48,28 @@ export function useSettingsPage() {
|
||||
const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources || []);
|
||||
setSubscriptions(settings.subscriptions || []);
|
||||
setSortBy(settings.sortBy);
|
||||
setRealtimeLatency(settings.realtimeLatency);
|
||||
setSearchDisplayMode(settings.searchDisplayMode);
|
||||
setFullscreenType(settings.fullscreenType);
|
||||
setProxyMode(settings.proxyMode);
|
||||
setSeekStepSeconds(settings.seekStepSeconds);
|
||||
setRememberScrollPosition(settings.rememberScrollPosition);
|
||||
setLocale(settings.locale);
|
||||
setDanmakuApiUrl(settings.danmakuApiUrl);
|
||||
setDanmakuOpacity(settings.danmakuOpacity);
|
||||
setDanmakuFontSize(settings.danmakuFontSize);
|
||||
setDanmakuDisplayArea(settings.danmakuDisplayArea);
|
||||
setBlockedCategories(settings.blockedCategories || []);
|
||||
const syncFromStore = () => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources || []);
|
||||
setSubscriptions(settings.subscriptions || []);
|
||||
setSortBy(settings.sortBy);
|
||||
setRealtimeLatency(settings.realtimeLatency);
|
||||
setSearchDisplayMode(settings.searchDisplayMode);
|
||||
setFullscreenType(settings.fullscreenType);
|
||||
setProxyMode(settings.proxyMode);
|
||||
setSeekStepSeconds(settings.seekStepSeconds);
|
||||
setRememberScrollPosition(settings.rememberScrollPosition);
|
||||
setLocale(settings.locale);
|
||||
setVideoTogetherEnabled(settings.videoTogetherEnabled);
|
||||
setDanmakuApiUrl(settings.danmakuApiUrl);
|
||||
setDanmakuOpacity(settings.danmakuOpacity);
|
||||
setDanmakuFontSize(settings.danmakuFontSize);
|
||||
setDanmakuDisplayArea(settings.danmakuDisplayArea);
|
||||
setBlockedCategories(settings.blockedCategories || []);
|
||||
};
|
||||
|
||||
syncFromStore();
|
||||
return settingsStore.subscribe(syncFromStore);
|
||||
}, []);
|
||||
|
||||
const handleSourcesChange = (newSources: VideoSource[]) => {
|
||||
@@ -283,6 +290,15 @@ export function useSettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoTogetherEnabledChange = (enabled: boolean) => {
|
||||
setVideoTogetherEnabled(enabled);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
videoTogetherEnabled: enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLocaleChange = (newLocale: LocaleOption) => {
|
||||
setLocale(newLocale);
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
@@ -392,6 +408,8 @@ export function useSettingsPage() {
|
||||
handleRememberScrollPositionChange,
|
||||
locale,
|
||||
handleLocaleChange,
|
||||
videoTogetherEnabled,
|
||||
handleVideoTogetherEnabledChange,
|
||||
danmakuApiUrl,
|
||||
handleDanmakuApiUrlChange,
|
||||
danmakuOpacity,
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { AppVersionSettings } from '@/components/settings/AppVersionSettings';
|
||||
import { UserSourceSettings } from '@/components/settings/UserSourceSettings';
|
||||
import { UserDanmakuSettings } from '@/components/settings/UserDanmakuSettings';
|
||||
import { PermissionGate } from '@/components/PermissionGate';
|
||||
@@ -57,7 +58,9 @@ export default function SettingsPage() {
|
||||
handleProxyModeChange,
|
||||
handleSeekStepSecondsChange,
|
||||
rememberScrollPosition,
|
||||
videoTogetherEnabled,
|
||||
handleRememberScrollPositionChange,
|
||||
handleVideoTogetherEnabledChange,
|
||||
locale,
|
||||
handleLocaleChange,
|
||||
danmakuApiUrl,
|
||||
@@ -78,6 +81,8 @@ export default function SettingsPage() {
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
|
||||
<AppVersionSettings />
|
||||
|
||||
{/* Account Settings */}
|
||||
<AccountSettings />
|
||||
|
||||
@@ -90,6 +95,8 @@ export default function SettingsPage() {
|
||||
onProxyModeChange={handleProxyModeChange}
|
||||
seekStepSeconds={seekStepSeconds}
|
||||
onSeekStepSecondsChange={handleSeekStepSecondsChange}
|
||||
videoTogetherEnabled={videoTogetherEnabled}
|
||||
onVideoTogetherEnabledChange={handleVideoTogetherEnabledChange}
|
||||
danmakuApiUrl={danmakuApiUrl}
|
||||
onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* Video Player Components */
|
||||
.kvideo-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -435,3 +439,54 @@
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/*
|
||||
* Keep fullscreen actions visible when the player itself becomes narrow.
|
||||
* Container queries are used instead of viewport queries because the player
|
||||
* can also be narrow inside the desktop player/sidebar grid.
|
||||
*/
|
||||
@container (max-width: 36rem) {
|
||||
.kvideo-container .player-controls-bar {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.kvideo-container .player-controls-row {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.kvideo-container .player-controls-left {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.kvideo-container .player-volume-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kvideo-container .player-controls-right {
|
||||
flex: 0 0 auto;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.kvideo-container .player-time-display {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 24rem) {
|
||||
.kvideo-container .player-controls-row {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.kvideo-container .player-controls-left,
|
||||
.kvideo-container .player-controls-right {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.kvideo-container .player-duration-display {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import { useConfigSync } from '@/lib/hooks/useConfigSync';
|
||||
import { getSession } from '@/lib/store/auth-store';
|
||||
|
||||
// 防抖函数,防止频繁请求
|
||||
function debounce(fn: Function, delay: number) {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
function debounce<Args extends unknown[]>(fn: (...args: Args) => void, delay: number) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
return (...args: Args) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
+271
-188
@@ -1,232 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSession, setSession } from '@/lib/store/auth-store';
|
||||
import { Lock, User } from 'lucide-react';
|
||||
import { clearSession, getSession, setSession, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { resolvePasswordGateState } from '@/lib/auth/password-gate-state';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
|
||||
import { useIPTVStore } from '@/lib/store/iptv-store';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Sync IPTV sources from environment variable.
|
||||
* Format: JSON array [{name, url}] or comma-separated URLs.
|
||||
*/
|
||||
type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
function syncIPTVSources(rawValue: string) {
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
|
||||
// Try JSON
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter((item: any) => item && typeof item.url === 'string');
|
||||
}
|
||||
} catch {
|
||||
// Try comma-separated URLs
|
||||
if (rawValue.includes('http')) {
|
||||
const urls = rawValue.split(',').map(u => u.trim()).filter(u => u.startsWith('http'));
|
||||
entries = urls.map((url, i) => ({
|
||||
name: urls.length > 1 ? `直播源 ${i + 1}` : '直播源',
|
||||
url,
|
||||
}));
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter((item: unknown): item is { name: string; url: string } => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const candidate = item as { name?: unknown; url?: unknown };
|
||||
return typeof candidate.url === 'string';
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (rawValue.includes('http')) {
|
||||
const urls = rawValue.split(',').map((value) => value.trim()).filter((value) => value.startsWith('http'));
|
||||
entries = urls.map((url, index) => ({
|
||||
name: urls.length > 1 ? `直播源 ${index + 1}` : '直播源',
|
||||
url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
iptvStore.syncBuiltinSources(entries);
|
||||
iptvStore.syncBuiltinSources(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync merge sources setting from environment variable.
|
||||
* Value: 'true' or '1' to enable grouped display mode.
|
||||
*/
|
||||
function syncMergeSources(rawValue: string) {
|
||||
const enabled = rawValue === 'true' || rawValue === '1';
|
||||
if (!enabled) return;
|
||||
const enabled = rawValue === 'true' || rawValue === '1';
|
||||
if (!enabled) return;
|
||||
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.searchDisplayMode !== 'grouped') {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
searchDisplayMode: 'grouped',
|
||||
});
|
||||
}
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.searchDisplayMode !== 'grouped') {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
searchDisplayMode: 'grouped',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function syncDanmakuApiUrl(rawValue: string) {
|
||||
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
|
||||
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
|
||||
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.danmakuApiUrl !== rawValue) {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
danmakuApiUrl: rawValue,
|
||||
});
|
||||
}
|
||||
const settings = settingsStore.getSettings();
|
||||
if (settings.danmakuApiUrl !== rawValue) {
|
||||
settingsStore.saveSettings({
|
||||
...settings,
|
||||
danmakuApiUrl: rawValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) {
|
||||
// Enable background subscription syncing globally
|
||||
useSubscriptionSync();
|
||||
function applyRuntimeConfig(data: {
|
||||
subscriptionSources?: string;
|
||||
iptvSources?: string;
|
||||
mergeSources?: string;
|
||||
danmakuApiUrl?: string;
|
||||
}) {
|
||||
if (data.subscriptionSources) {
|
||||
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
|
||||
}
|
||||
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [hasAuth, setHasAuth] = useState(initialHasAuth);
|
||||
const [persistSession, setPersistSession] = useState(true);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
if (data.iptvSources) {
|
||||
syncIPTVSources(data.iptvSources);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (data.mergeSources) {
|
||||
syncMergeSources(data.mergeSources);
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
// Check if already has a valid session
|
||||
const session = getSession();
|
||||
const isAuthenticated = !!session;
|
||||
if (data.danmakuApiUrl) {
|
||||
syncDanmakuApiUrl(data.danmakuApiUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fast check
|
||||
const localLocked = initialHasAuth && !isAuthenticated;
|
||||
if (mounted) {
|
||||
setIsLocked(localLocked);
|
||||
setIsClient(true);
|
||||
}
|
||||
function toAuthSession(session: {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: AuthSession['role'];
|
||||
customPermissions?: AuthSession['customPermissions'];
|
||||
mode?: AuthSession['mode'];
|
||||
}): AuthSession {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions,
|
||||
mode: session.mode,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch remote config & sync
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
export function PasswordGate({
|
||||
children,
|
||||
hasAuth: initialHasAuth,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hasAuth: boolean;
|
||||
}) {
|
||||
useSubscriptionSync();
|
||||
|
||||
const data = await res.json();
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [persistSession, setPersistSession] = useState(true);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
||||
|
||||
if (mounted) {
|
||||
setHasAuth(data.hasAuth);
|
||||
setPersistSession(data.persistSession);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// Sync subscriptions
|
||||
if (data.subscriptionSources) {
|
||||
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
|
||||
}
|
||||
const init = async () => {
|
||||
const mirroredSession = getSession();
|
||||
|
||||
// Sync IPTV sources from env
|
||||
if (data.iptvSources) {
|
||||
syncIPTVSources(data.iptvSources);
|
||||
}
|
||||
try {
|
||||
const [configRes, sessionRes] = await Promise.all([
|
||||
fetch('/api/auth'),
|
||||
fetch('/api/auth/session'),
|
||||
]);
|
||||
|
||||
// Sync merge sources setting from env
|
||||
if (data.mergeSources) {
|
||||
syncMergeSources(data.mergeSources);
|
||||
}
|
||||
|
||||
if (data.danmakuApiUrl) {
|
||||
syncDanmakuApiUrl(data.danmakuApiUrl);
|
||||
}
|
||||
|
||||
// Re-evaluate lock status with confirmed server state
|
||||
const confirmLocked = data.hasAuth && !isAuthenticated;
|
||||
setIsLocked(confirmLocked);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("PasswordGate init failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, [initialHasAuth]);
|
||||
|
||||
const handleUnlock = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.valid) {
|
||||
setSession({
|
||||
profileId: data.profileId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
customPermissions: data.customPermissions,
|
||||
}, data.persistSession ?? persistSession);
|
||||
|
||||
// Reload to re-initialize stores with profiled keys
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// API error
|
||||
if (!configRes.ok) {
|
||||
throw new Error('Failed to fetch auth config');
|
||||
}
|
||||
|
||||
// Password didn't match
|
||||
setError(true);
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
setTimeout(() => form?.classList.remove('animate-shake'), 500);
|
||||
const config = await configRes.json();
|
||||
const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null };
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setPersistSession(config.persistSession);
|
||||
setLoginMode(config.loginMode || 'none');
|
||||
applyRuntimeConfig(config);
|
||||
|
||||
const serverSession = sessionStatus.authenticated && sessionStatus.session
|
||||
? toAuthSession(sessionStatus.session)
|
||||
: null;
|
||||
const gateState = resolvePasswordGateState({
|
||||
hasAuth: !!config.hasAuth,
|
||||
serverSession,
|
||||
mirroredSession,
|
||||
persistSession: config.persistSession,
|
||||
});
|
||||
|
||||
if (gateState.action === 'unlock-session') {
|
||||
setSession(gateState.session, gateState.persistSession);
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gateState.action === 'unlock-public') {
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gateState.clearMirroredSession) {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
setIsLocked(true);
|
||||
setIsClient(true);
|
||||
} catch {
|
||||
if (!mounted) return;
|
||||
setIsLocked(initialHasAuth && !mirroredSession);
|
||||
setIsClient(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isClient) return null; // Prevent hydration mismatch
|
||||
init();
|
||||
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [initialHasAuth]);
|
||||
|
||||
const handleUnlock = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsValidating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: loginMode === 'managed' ? username : undefined,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status === 503) {
|
||||
setError('认证存储暂时不可用,请检查 Upstash Redis 配置');
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.valid && data.session) {
|
||||
setSession(toAuthSession(data.session), data.persistSession ?? persistSession);
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors and show the same message as invalid credentials.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
|
||||
<div className="w-full max-w-md p-4">
|
||||
<form
|
||||
id="password-form"
|
||||
onSubmit={handleUnlock}
|
||||
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
setTimeout(() => form?.classList.remove('animate-shake'), 500);
|
||||
};
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold">访问受限</h2>
|
||||
<p className="text-[var(--text-color-secondary)]">请输入访问密码以继续</p>
|
||||
</div>
|
||||
if (!isClient) return null;
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
placeholder="输入密码..."
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'
|
||||
} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center animate-pulse">
|
||||
密码错误
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isValidating}
|
||||
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? '验证中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
const showManagedFields = loginMode === 'managed';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
|
||||
<div className="w-full max-w-md p-4">
|
||||
<form
|
||||
id="password-form"
|
||||
onSubmit={handleUnlock}
|
||||
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
|
||||
<Lock size={32} />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold">访问受限</h2>
|
||||
<p className="text-[var(--text-color-secondary)]">
|
||||
{showManagedFields ? '请输入用户名和密码以继续' : '请输入访问密码以继续'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
{showManagedFields && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<User size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="输入用户名..."
|
||||
className="w-full pl-11 pr-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={showManagedFields ? '输入密码...' : '输入密码...'}
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
autoFocus={!showManagedFields}
|
||||
autoComplete={showManagedFields ? 'current-password' : 'off'}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center animate-pulse">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isValidating}
|
||||
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? '验证中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
@@ -236,6 +319,6 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
|
||||
animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked';
|
||||
|
||||
export function PremiumPasswordGate({ children }: { children: React.ReactNode }) {
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [hasPremiumAuth, setHasPremiumAuth] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
@@ -21,14 +20,19 @@ export function PremiumPasswordGate({ children }: { children: React.ReactNode })
|
||||
const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await res.json();
|
||||
const [configRes, sessionRes] = await Promise.all([
|
||||
fetch('/api/auth'),
|
||||
fetch('/api/auth/session'),
|
||||
]);
|
||||
if (!configRes.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await configRes.json();
|
||||
const sessionData = sessionRes.ok ? await sessionRes.json() : null;
|
||||
const isAdminSession = !!sessionData?.session &&
|
||||
(sessionData.session.role === 'admin' || sessionData.session.role === 'super_admin');
|
||||
|
||||
if (mounted) {
|
||||
setHasPremiumAuth(data.hasPremiumAuth);
|
||||
// If no premium password configured, allow access
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked);
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked && !isAdminSession);
|
||||
setIsClient(true);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
|
||||
|
||||
const defaultRuntimeFeatures: RuntimeFeatures = {
|
||||
deploymentProvider: 'self-hosted',
|
||||
deploymentProviderLabel: '自托管',
|
||||
restrictedManagedDeployment: false,
|
||||
mediaProxyEnabled: true,
|
||||
iptvEnabled: true,
|
||||
restrictionSummary: null,
|
||||
};
|
||||
|
||||
const RuntimeFeaturesContext = createContext<RuntimeFeatures>(defaultRuntimeFeatures);
|
||||
|
||||
interface RuntimeFeaturesProviderProps {
|
||||
initialFeatures: RuntimeFeatures;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RuntimeFeaturesProvider({ initialFeatures, children }: RuntimeFeaturesProviderProps) {
|
||||
return (
|
||||
<RuntimeFeaturesContext.Provider value={initialFeatures}>
|
||||
{children}
|
||||
</RuntimeFeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRuntimeFeatures(): RuntimeFeatures {
|
||||
return useContext(RuntimeFeaturesContext);
|
||||
}
|
||||
@@ -4,17 +4,27 @@ import { useEffect } from 'react';
|
||||
|
||||
export function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').then(
|
||||
(registration) => {
|
||||
// Registration successful
|
||||
})
|
||||
.catch((err) => {
|
||||
// Registration failed
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const registerServiceWorker = () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
registration.update().catch(() => {
|
||||
// Ignore update check errors.
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore registration failures.
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
registerServiceWorker();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', registerServiceWorker, { once: true });
|
||||
return () => window.removeEventListener('load', registerServiceWorker);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const SiteIconContext = createContext('/icon.png');
|
||||
|
||||
export function SiteIconProvider({
|
||||
children,
|
||||
iconSrc,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
iconSrc: string;
|
||||
}) {
|
||||
return <SiteIconContext.Provider value={iconSrc}>{children}</SiteIconContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSiteIcon() {
|
||||
return useContext(SiteIconContext);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
const HIDE_STYLE_ID = 'kvideo-videotogether-visibility';
|
||||
const SCRIPT_ID = 'kvideo-videotogether-script';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
VideoTogetherLoading?: boolean;
|
||||
VideoTogetherSettingEnabled?: boolean;
|
||||
videoTogetherWebsiteSettingUrl?: string;
|
||||
videoTogetherExtension?: unknown;
|
||||
videoTogetherFlyPannel?: {
|
||||
Minimize?: (isDefault?: boolean) => void;
|
||||
} | null;
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoTogetherControllerProps {
|
||||
envEnabled: boolean;
|
||||
scriptUrl: string;
|
||||
settingUrl?: string;
|
||||
}
|
||||
|
||||
function isSupportedRoute(pathname: string | null): boolean {
|
||||
return pathname?.startsWith('/player') === true || pathname?.startsWith('/iptv') === true;
|
||||
}
|
||||
|
||||
function syncMinimizedDefaults(forceCurrentPageMinimized: boolean) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('EnableMiniBar', JSON.stringify(true));
|
||||
localStorage.setItem('MinimiseDefault', JSON.stringify(true));
|
||||
|
||||
if (forceCurrentPageMinimized) {
|
||||
localStorage.setItem('VideoTogetherMinimizedHere', '1');
|
||||
window.videoTogetherFlyPannel?.Minimize?.(true);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage failures so player pages continue working.
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibility(hidden: boolean) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let style = document.getElementById(HIDE_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = HIDE_STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
style.textContent = hidden
|
||||
? `
|
||||
#VideoTogetherWrapper,
|
||||
#VideoTogetherfullscreenSWrapper,
|
||||
#videoTogetherLoading,
|
||||
#videoTogetherTxtMsgTouch {
|
||||
display: none !important;
|
||||
}
|
||||
`
|
||||
: `
|
||||
#videoTogetherLoading {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function VideoTogetherController({
|
||||
envEnabled,
|
||||
scriptUrl,
|
||||
settingUrl,
|
||||
}: VideoTogetherControllerProps) {
|
||||
const pathname = usePathname();
|
||||
const [videoTogetherEnabled, setVideoTogetherEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
setVideoTogetherEnabled(settingsStore.getSettings().videoTogetherEnabled);
|
||||
};
|
||||
|
||||
sync();
|
||||
return settingsStore.subscribe(sync);
|
||||
}, []);
|
||||
|
||||
const supportedRoute = isSupportedRoute(pathname);
|
||||
const shouldActivate = envEnabled && videoTogetherEnabled && supportedRoute;
|
||||
|
||||
useEffect(() => {
|
||||
if (!envEnabled || !videoTogetherEnabled) {
|
||||
updateVisibility(true);
|
||||
return;
|
||||
}
|
||||
|
||||
syncMinimizedDefaults(supportedRoute);
|
||||
}, [envEnabled, videoTogetherEnabled, supportedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
updateVisibility(!shouldActivate);
|
||||
}, [shouldActivate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldActivate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingUrl) {
|
||||
window.videoTogetherWebsiteSettingUrl = settingUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
document.getElementById(SCRIPT_ID) ||
|
||||
document.getElementById('videotogether-script') ||
|
||||
window.VideoTogetherLoading ||
|
||||
window.videoTogetherExtension ||
|
||||
window.videoTogetherFlyPannel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = scriptUrl;
|
||||
script.async = true;
|
||||
|
||||
document.body.appendChild(script);
|
||||
}, [scriptUrl, settingUrl, shouldActivate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
const DEFAULT_VIDEOTOGETHER_SCRIPT_URL =
|
||||
'https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js';
|
||||
|
||||
export function VideoTogetherScript() {
|
||||
if (process.env.VIDEOTOGETHER_ENABLED === 'false') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scriptUrl = process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL;
|
||||
const settingUrl = process.env.VIDEOTOGETHER_SETTING_URL?.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
{settingUrl ? (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.videoTogetherWebsiteSettingUrl = ${JSON.stringify(settingUrl)};`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Script
|
||||
id="videotogether-script"
|
||||
src={scriptUrl}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
|
||||
import { VideoCard } from '@/components/search/VideoCard';
|
||||
import { FavoritesEmptyState } from './FavoritesEmptyState';
|
||||
import type { FavoriteItem, Video } from '@/lib/types';
|
||||
|
||||
interface FavoritesGridProps {
|
||||
favorites: FavoriteItem[];
|
||||
isPremium?: boolean;
|
||||
}
|
||||
|
||||
export const FavoritesGrid = memo(function FavoritesGrid({
|
||||
favorites,
|
||||
isPremium = false
|
||||
}: FavoritesGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(24);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Convert FavoriteItem to Video format
|
||||
const videos: Video[] = useMemo(
|
||||
() =>
|
||||
favorites.map((favorite) => ({
|
||||
vod_id: favorite.videoId,
|
||||
vod_name: favorite.title,
|
||||
vod_pic: favorite.poster,
|
||||
vod_remarks: favorite.remarks,
|
||||
vod_year: favorite.year,
|
||||
type_name: favorite.type,
|
||||
source: favorite.source,
|
||||
sourceName: favorite.sourceName,
|
||||
})),
|
||||
[favorites]
|
||||
);
|
||||
|
||||
// Setup intersection observer for infinite scroll
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const first = entries[0];
|
||||
if (first.isIntersecting && visibleCount < videos.length) {
|
||||
setVisibleCount((prev) => Math.min(prev + 24, videos.length));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observerRef.current.observe(loadMoreRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [visibleCount, videos.length]);
|
||||
|
||||
const handleCardClick = useCallback((
|
||||
e: React.MouseEvent,
|
||||
cardId: string,
|
||||
videoUrl: string
|
||||
) => {
|
||||
setActiveCardId(cardId);
|
||||
}, []);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return <FavoritesEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-6">
|
||||
{videos.slice(0, visibleCount).map((video) => {
|
||||
const cardId = `${video.source}:${video.vod_id}`;
|
||||
const videoUrl = `/player?id=${video.vod_id}&source=${video.source}&title=${encodeURIComponent(video.vod_name)}${isPremium ? '&premium=1' : ''}`;
|
||||
const isActive = activeCardId === cardId;
|
||||
|
||||
return (
|
||||
<VideoCard
|
||||
key={cardId}
|
||||
video={video}
|
||||
videoUrl={videoUrl}
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
isPremium={isPremium}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load more trigger */}
|
||||
{visibleCount < videos.length && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="h-20 w-full flex items-center justify-center opacity-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { FavoriteItem } from '@/lib/types';
|
||||
import { FavoritesItem } from './FavoritesItem';
|
||||
import { FavoritesEmptyState } from './FavoritesEmptyState';
|
||||
import { keepRenderableFavorites } from '@/lib/utils/sync-records';
|
||||
|
||||
interface FavoritesListProps {
|
||||
favorites: FavoriteItem[];
|
||||
@@ -13,13 +14,15 @@ interface FavoritesListProps {
|
||||
}
|
||||
|
||||
export function FavoritesList({ favorites, onRemove, isPremium = false }: FavoritesListProps) {
|
||||
if (favorites.length === 0) {
|
||||
const renderableFavorites = keepRenderableFavorites(favorites);
|
||||
|
||||
if (renderableFavorites.length === 0) {
|
||||
return <FavoritesEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-2 scroll-smooth">
|
||||
{favorites.map((item) => (
|
||||
{renderableFavorites.map((item) => (
|
||||
<FavoritesItem
|
||||
key={`${item.source}:${item.videoId}`}
|
||||
item={item}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Navbar } from '@/components/layout/Navbar';
|
||||
import { FavoritesPageHeader } from '@/components/favorites/FavoritesPageHeader';
|
||||
import { FavoritesGrid } from '@/components/favorites/FavoritesGrid';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useFavorites } from '@/lib/store/favorites-store';
|
||||
|
||||
interface FavoritesPageContentProps {
|
||||
isPremium?: boolean;
|
||||
}
|
||||
|
||||
export function FavoritesPageContent({
|
||||
isPremium = false,
|
||||
}: FavoritesPageContentProps) {
|
||||
const router = useRouter();
|
||||
const { favorites, clearFavorites } = useFavorites(isPremium);
|
||||
const [sortBy, setSortBy] = useState<'date' | 'title'>('date');
|
||||
const [isClearDialogOpen, setIsClearDialogOpen] = useState(false);
|
||||
|
||||
const sortedFavorites = useMemo(() => {
|
||||
if (sortBy === 'title') {
|
||||
return [...favorites].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
|
||||
}
|
||||
|
||||
return favorites;
|
||||
}, [favorites, sortBy]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isPremium
|
||||
? 'min-h-screen bg-black'
|
||||
: 'min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed'
|
||||
}
|
||||
>
|
||||
<Navbar
|
||||
onReset={() => router.push(isPremium ? '/premium' : '/')}
|
||||
isPremiumMode={isPremium}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8 space-y-8">
|
||||
<FavoritesPageHeader
|
||||
count={favorites.length}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
onClearAll={() => setIsClearDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<FavoritesGrid favorites={sortedFavorites} isPremium={isPremium} />
|
||||
</main>
|
||||
|
||||
<FavoritesSidebar isPremium={isPremium} />
|
||||
<WatchHistorySidebar isPremium={isPremium} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isClearDialogOpen}
|
||||
title="清空收藏"
|
||||
message="确定要清空所有收藏吗?此操作不可撤销。"
|
||||
confirmText="清空"
|
||||
cancelText="取消"
|
||||
onConfirm={() => {
|
||||
clearFavorites();
|
||||
setIsClearDialogOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsClearDialogOpen(false)}
|
||||
dangerous
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FavoritesPageFallbackProps {
|
||||
isPremium?: boolean;
|
||||
}
|
||||
|
||||
export function FavoritesPageFallback({
|
||||
isPremium = false,
|
||||
}: FavoritesPageFallbackProps) {
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen flex items-center justify-center${
|
||||
isPremium ? ' bg-black' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface FavoritesPageHeaderProps {
|
||||
count: number;
|
||||
sortBy: 'date' | 'title';
|
||||
onSortChange: (sort: 'date' | 'title') => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function FavoritesPageHeader({
|
||||
count,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
onClearAll
|
||||
}: FavoritesPageHeaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-2 text-[var(--accent-color)] hover:underline mb-4 cursor-pointer"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回上一页
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)]">
|
||||
<Icons.Heart size={24} className="text-[var(--text-color)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--text-color)]">我的收藏</h1>
|
||||
<p className="text-[var(--text-color-secondary)]">
|
||||
共 {count} 个视频
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Sort buttons */}
|
||||
<div className="flex items-center gap-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] p-1">
|
||||
<button
|
||||
onClick={() => onSortChange('date')}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-sm transition-all ${
|
||||
sortBy === 'date'
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
最新添加
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSortChange('title')}
|
||||
className={`px-3 py-1.5 rounded-[var(--radius-full)] text-sm transition-all ${
|
||||
sortBy === 'title'
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
标题排序
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Clear all button */}
|
||||
{count > 0 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="px-4 py-2 rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all text-sm flex items-center gap-2"
|
||||
>
|
||||
<Icons.Trash size={16} />
|
||||
清空收藏
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
import { HistoryEmptyState } from './HistoryEmptyState';
|
||||
import type { VideoHistoryItem } from '@/lib/types';
|
||||
import { keepRenderableHistory } from '@/lib/utils/sync-records';
|
||||
|
||||
interface HistoryListProps {
|
||||
history: VideoHistoryItem[];
|
||||
@@ -9,16 +10,18 @@ interface HistoryListProps {
|
||||
}
|
||||
|
||||
export function HistoryList({ history, onRemove, isPremium = false }: HistoryListProps) {
|
||||
const renderableHistory = keepRenderableHistory(history);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
WebkitOverflowScrolling: 'touch'
|
||||
}}>
|
||||
{history.length === 0 ? (
|
||||
{renderableHistory.length === 0 ? (
|
||||
<HistoryEmptyState />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{history.map((item) => (
|
||||
{renderableHistory.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.showIdentifier}
|
||||
item={item}
|
||||
|
||||
@@ -6,13 +6,21 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { TagManager } from './TagManager';
|
||||
import { MovieGrid } from './MovieGrid';
|
||||
import { useTagManager } from './hooks/useTagManager';
|
||||
import { usePopularMovies } from './hooks/usePopularMovies';
|
||||
import { usePersonalizedRecommendations } from './hooks/usePersonalizedRecommendations';
|
||||
|
||||
interface DoubanMovie {
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PopularFeaturesProps {
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
@@ -46,19 +54,10 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
loadMoreRef: recommendLoadMoreRef,
|
||||
} = usePersonalizedRecommendations(false);
|
||||
|
||||
// Track whether the recommendation tab is active
|
||||
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
|
||||
|
||||
// Sync selection when hasHistory changes after Zustand hydration from localStorage.
|
||||
// On first render the store is empty (hasHistory=false), so useState captures false.
|
||||
// Once hydration completes and hasHistory becomes true, auto-select the recommendation tab.
|
||||
useEffect(() => {
|
||||
if (hasHistory) {
|
||||
setIsRecommendSelected(true);
|
||||
}
|
||||
}, [hasHistory]);
|
||||
const [isRecommendSelected, setIsRecommendSelected] = useState(false);
|
||||
|
||||
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
|
||||
const isTagManagementMode = showTagManager;
|
||||
|
||||
const {
|
||||
movies,
|
||||
@@ -72,7 +71,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
contentType
|
||||
);
|
||||
|
||||
const handleMovieClick = (movie: any) => {
|
||||
const handleMovieClick = (movie: DoubanMovie) => {
|
||||
if (onSearch) {
|
||||
onSearch(movie.title);
|
||||
}
|
||||
@@ -94,7 +93,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
|
||||
{!effectiveRecommendSelected && (
|
||||
{!isTagManagementMode && !effectiveRecommendSelected && (
|
||||
<div className="mb-10 flex justify-center">
|
||||
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
||||
{/* Sliding Indicator */}
|
||||
@@ -145,24 +144,26 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
{effectiveRecommendSelected ? (
|
||||
<MovieGrid
|
||||
movies={recommendMovies}
|
||||
loading={recommendLoading}
|
||||
hasMore={recommendHasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={recommendPrefetchRef}
|
||||
loadMoreRef={recommendLoadMoreRef}
|
||||
/>
|
||||
) : (
|
||||
<MovieGrid
|
||||
movies={movies}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
{!isTagManagementMode && (
|
||||
effectiveRecommendSelected ? (
|
||||
<MovieGrid
|
||||
movies={recommendMovies}
|
||||
loading={recommendLoading}
|
||||
hasMore={recommendHasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={recommendPrefetchRef}
|
||||
loadMoreRef={recommendLoadMoreRef}
|
||||
/>
|
||||
) : (
|
||||
<MovieGrid
|
||||
movies={movies}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export function SortableTag({
|
||||
>
|
||||
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagSelect(tag.id)}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
|
||||
@@ -64,6 +65,7 @@ export function SortableTag({
|
||||
</button>
|
||||
{showTagManager && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTagDelete(tag.id);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface TagInputProps {
|
||||
newTagInput: string;
|
||||
onNewTagInputChange: (value: string) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { SortableTag, Tag } from './SortableTag';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
@@ -115,12 +116,17 @@ export function TagList({
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
|
||||
className={`mb-8 flex items-center gap-3 pb-3 pt-2 px-1 scrollbar-hide ${
|
||||
showTagManager
|
||||
? 'flex-wrap overflow-visible'
|
||||
: 'overflow-x-auto'
|
||||
}`}
|
||||
>
|
||||
{/* Recommendation Tag — non-draggable, rendered before sortable tags */}
|
||||
{recommendTag && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={recommendTag.onSelect}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none flex items-center gap-1.5
|
||||
@@ -137,7 +143,7 @@ export function TagList({
|
||||
)}
|
||||
<SortableContext
|
||||
items={tags.map((t) => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
strategy={showTagManager ? rectSortingStrategy : horizontalListSortingStrategy}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<SortableTag
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import type { Tag } from '../SortableTag';
|
||||
|
||||
const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' };
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_';
|
||||
|
||||
const ensureDefaultTag = (tags: Tag[]) => {
|
||||
if (tags.some((tag) => tag.id === DEFAULT_TAG.id || tag.value === DEFAULT_TAG.value)) {
|
||||
return tags.map((tag) =>
|
||||
tag.id === DEFAULT_TAG.id || tag.value === DEFAULT_TAG.value
|
||||
? { ...DEFAULT_TAG, ...tag, id: DEFAULT_TAG.id, value: DEFAULT_TAG.value }
|
||||
: tag
|
||||
);
|
||||
}
|
||||
|
||||
return [DEFAULT_TAG, ...tags];
|
||||
};
|
||||
|
||||
export function useTagManager() {
|
||||
const [contentType, setContentType] = useState<'movie' | 'tv'>(() => {
|
||||
if (typeof window === 'undefined') return 'movie';
|
||||
const saved = localStorage.getItem('kvideo_default_content_type');
|
||||
return saved === 'tv' ? 'tv' : 'movie';
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value);
|
||||
const [tags, setTags] = useState<any[]>([]);
|
||||
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.id);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
const [newTagInput, setNewTagInput] = useState('');
|
||||
const [showTagManager, setShowTagManager] = useState(false);
|
||||
@@ -32,7 +45,8 @@ export function useTagManager() {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
setTags(JSON.parse(saved));
|
||||
const parsedTags = JSON.parse(saved);
|
||||
setTags(Array.isArray(parsedTags) ? ensureDefaultTag(parsedTags) : [DEFAULT_TAG]);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved tags', e);
|
||||
@@ -51,12 +65,7 @@ export function useTagManager() {
|
||||
value: label,
|
||||
}));
|
||||
|
||||
// If "热门" isn't in the list, add it to the front
|
||||
if (!mappedTags.some((t: any) => t.value === '热门')) {
|
||||
mappedTags.unshift(DEFAULT_TAG);
|
||||
}
|
||||
|
||||
setTags(mappedTags);
|
||||
setTags(ensureDefaultTag(mappedTags));
|
||||
// Also save to localStorage to avoid repeated fetches if desired
|
||||
// Actually, let's just keep them in memory for now unless they customize
|
||||
} else {
|
||||
@@ -71,10 +80,10 @@ export function useTagManager() {
|
||||
};
|
||||
|
||||
loadTags();
|
||||
setSelectedTag(DEFAULT_TAG.value);
|
||||
setSelectedTag(DEFAULT_TAG.id);
|
||||
}, [contentType, storageKey]);
|
||||
|
||||
const saveTags = (newTags: any[]) => {
|
||||
const saveTags = (newTags: Tag[]) => {
|
||||
setTags(newTags);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newTags));
|
||||
};
|
||||
@@ -92,9 +101,11 @@ export function useTagManager() {
|
||||
};
|
||||
|
||||
const handleDeleteTag = (tagId: string) => {
|
||||
if (tagId === DEFAULT_TAG.id) return;
|
||||
|
||||
saveTags(tags.filter(t => t.id !== tagId));
|
||||
if (selectedTag === tagId) {
|
||||
setSelectedTag('popular');
|
||||
setSelectedTag(DEFAULT_TAG.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,19 +122,16 @@ export function useTagManager() {
|
||||
label,
|
||||
value: label,
|
||||
}));
|
||||
if (!mappedTags.some((t: any) => t.value === '热门')) {
|
||||
mappedTags.unshift(DEFAULT_TAG);
|
||||
}
|
||||
setTags(mappedTags);
|
||||
setTags(ensureDefaultTag(mappedTags));
|
||||
} else {
|
||||
setTags([DEFAULT_TAG]);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setTags([DEFAULT_TAG]);
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
setSelectedTag(DEFAULT_TAG.value);
|
||||
setSelectedTag(DEFAULT_TAG.id);
|
||||
setShowTagManager(false);
|
||||
};
|
||||
|
||||
|
||||
+115
-22
@@ -13,6 +13,7 @@ import { Icons } from '@/components/ui/Icon';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
import type { IPTVSource } from '@/lib/store/iptv-store';
|
||||
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
|
||||
import { shouldHidePlayerCursor } from '@/lib/player/cursor-visibility';
|
||||
|
||||
const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
|
||||
enableWorker: true,
|
||||
@@ -91,6 +92,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
const [isLive, setIsLive] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [seekWindow, setSeekWindow] = useState<{ start: number; end: number; duration: number } | null>(null);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||||
@@ -105,6 +107,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
|
||||
// Whether we have multi-source data
|
||||
const hasMultiSource = channelsBySource && sources && sources.length > 0;
|
||||
const activeSourceId = channel.sourceId || null;
|
||||
const activeGroupKey = activeSourceId && channel.group ? `${activeSourceId}::${channel.group}` : null;
|
||||
const activeSource = useMemo(
|
||||
() => (activeSourceId && sources ? sources.find((source) => source.id === activeSourceId) || null : null),
|
||||
[activeSourceId, sources]
|
||||
);
|
||||
|
||||
// Get current route URL
|
||||
const routes = channel.routes || [channel.url];
|
||||
@@ -167,20 +175,31 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onTimeUpdate = () => {
|
||||
const range = getSeekRange(video);
|
||||
setCurrentTime(video.currentTime);
|
||||
const dur = video.duration;
|
||||
if (isFinite(dur) && dur > 0) {
|
||||
setDuration(dur);
|
||||
setSeekWindow(range);
|
||||
if (range) {
|
||||
setDuration(range.duration);
|
||||
setIsLive(false);
|
||||
} else {
|
||||
const dur = video.duration;
|
||||
if (isFinite(dur) && dur > 0) {
|
||||
setDuration(dur);
|
||||
}
|
||||
setIsLive(true);
|
||||
}
|
||||
};
|
||||
const onDurationChange = () => {
|
||||
const dur = video.duration;
|
||||
if (isFinite(dur) && dur > 0) {
|
||||
setDuration(dur);
|
||||
const range = getSeekRange(video);
|
||||
setSeekWindow(range);
|
||||
if (range) {
|
||||
setDuration(range.duration);
|
||||
setIsLive(false);
|
||||
} else {
|
||||
const dur = video.duration;
|
||||
if (isFinite(dur) && dur > 0) {
|
||||
setDuration(dur);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onVolumeChange = () => {
|
||||
@@ -212,6 +231,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
setIsLive(true);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setSeekWindow(null);
|
||||
|
||||
// Clean up previous
|
||||
if (hlsRef.current) {
|
||||
@@ -310,7 +330,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
hlsProxy.attachMedia(video);
|
||||
|
||||
// Filter HEVC levels for proxy attempt too
|
||||
hlsProxy.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
filterHEVCLevels(hlsProxy);
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
@@ -349,7 +369,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
// First try initial URL (direct or proxied based on custom headers)
|
||||
hls.loadSource(initialUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// Filter HEVC levels to prevent audio-only playback
|
||||
filterHEVCLevels(hls);
|
||||
markLoaded();
|
||||
@@ -469,14 +489,19 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
};
|
||||
|
||||
const progressPercent = useMemo(() => {
|
||||
const video = videoRef.current;
|
||||
const seekRange = video ? getSeekRange(video) : null;
|
||||
if (seekRange) {
|
||||
return Math.max(0, Math.min(100, ((currentTime - seekRange.start) / seekRange.duration) * 100));
|
||||
if (seekWindow) {
|
||||
return Math.max(0, Math.min(100, ((currentTime - seekWindow.start) / seekWindow.duration) * 100));
|
||||
}
|
||||
if (!duration) return 0;
|
||||
return Math.max(0, Math.min(100, (currentTime / duration) * 100));
|
||||
}, [currentTime, duration]);
|
||||
}, [currentTime, duration, seekWindow]);
|
||||
|
||||
const shouldHideCursor = shouldHidePlayerCursor({
|
||||
isFullscreen,
|
||||
isPlaying,
|
||||
showControls,
|
||||
hasInteractiveOverlay: showSidebar || showVolumeSlider || Boolean(error),
|
||||
});
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -567,24 +592,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
const isSearchMode = sidebarSearch.trim().length > 0;
|
||||
|
||||
// Toggle source expansion
|
||||
const toggleSource = (sourceId: string) => {
|
||||
const toggleSource = useCallback((sourceId: string) => {
|
||||
setExpandedSources(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sourceId)) next.delete(sourceId);
|
||||
else next.add(sourceId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Toggle group expansion
|
||||
const toggleGroup = (key: string) => {
|
||||
const toggleGroup = useCallback((key: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleActiveSource = useCallback(() => {
|
||||
if (!activeSourceId) return;
|
||||
toggleSource(activeSourceId);
|
||||
}, [activeSourceId, toggleSource]);
|
||||
|
||||
const toggleActiveGroup = useCallback(() => {
|
||||
if (!activeSourceId || !channel.group) return;
|
||||
setExpandedSources(prev => new Set(prev).add(activeSourceId));
|
||||
toggleGroup(`${activeSourceId}::${channel.group}`);
|
||||
}, [activeSourceId, channel.group, toggleGroup]);
|
||||
|
||||
// Render a channel button
|
||||
const renderChannelButton = (ch: M3UChannel, i: number) => {
|
||||
@@ -623,21 +659,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
// Render multi-level sidebar content
|
||||
const renderMultiLevelSidebar = () => {
|
||||
if (!channelsBySource || !sources) return null;
|
||||
const orderedSources = activeSourceId
|
||||
? [
|
||||
...sources.filter((source) => source.id === activeSourceId),
|
||||
...sources.filter((source) => source.id !== activeSourceId),
|
||||
]
|
||||
: sources;
|
||||
|
||||
return (
|
||||
<div className="p-1">
|
||||
{sources.map(source => {
|
||||
{orderedSources.map(source => {
|
||||
const sourceData = channelsBySource[source.id];
|
||||
if (!sourceData || sourceData.channels.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedSources.has(source.id);
|
||||
const isActiveSource = source.id === activeSourceId;
|
||||
const orderedGroups = isActiveSource && channel.group
|
||||
? [channel.group, ...sourceData.groups.filter((group) => group !== channel.group)]
|
||||
: sourceData.groups;
|
||||
|
||||
return (
|
||||
<div key={source.id} className="mb-1">
|
||||
{/* Source Header */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleSource(source.id); }}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium text-white/90 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
isActiveSource
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/90 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icons.TV size={14} className="flex-shrink-0 text-[var(--accent-color)]" />
|
||||
@@ -653,18 +703,23 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
{/* Source Content */}
|
||||
{isExpanded && (
|
||||
<div className="ml-2 border-l border-white/10 pl-1">
|
||||
{sourceData.groups.length > 0 ? (
|
||||
{orderedGroups.length > 0 ? (
|
||||
// Has groups — show group-level
|
||||
sourceData.groups.map(group => {
|
||||
orderedGroups.map(group => {
|
||||
const groupKey = `${source.id}::${group}`;
|
||||
const groupExpanded = expandedGroups.has(groupKey);
|
||||
const groupChannels = sourceData.channels.filter(ch => ch.group === group);
|
||||
const isActiveGroup = groupKey === activeGroupKey;
|
||||
|
||||
return (
|
||||
<div key={groupKey} className="mb-0.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleGroup(groupKey); }}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded text-xs text-white/60 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
className={`w-full flex items-center justify-between px-2 py-1.5 rounded text-xs transition-colors cursor-pointer ${
|
||||
isActiveGroup
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/60 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Icons.Tag size={12} className="flex-shrink-0" />
|
||||
@@ -734,6 +789,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-[9999] bg-black flex"
|
||||
style={{ cursor: shouldHideCursor ? 'none' : undefined }}
|
||||
onMouseMove={resetControlsTimeout}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-controls]') || (e.target as HTMLElement).closest('[data-sidebar]')) return;
|
||||
@@ -958,6 +1014,43 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(activeSource || channel.group) && (
|
||||
<div className="px-3 py-2 border-b border-white/10">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-white/30">当前</span>
|
||||
{activeSource && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleActiveSource();
|
||||
}}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
||||
activeSourceId && expandedSources.has(activeSourceId)
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
源: {activeSource.name}
|
||||
</button>
|
||||
)}
|
||||
{channel.group && activeGroupKey && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleActiveGroup();
|
||||
}}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
||||
expandedGroups.has(activeGroupKey)
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
标签: {channel.group}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { useSiteIcon } from '@/components/SiteIconProvider';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { siteConfig } from '@/lib/config/site-config';
|
||||
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -16,16 +18,20 @@ interface NavbarProps {
|
||||
|
||||
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
|
||||
const [session, setSessionState] = useState<AuthSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionState(getSession());
|
||||
}, []);
|
||||
const favoritesHref = isPremiumMode ? '/premium/favorites' : '/favorites';
|
||||
const [session] = useState<AuthSession | null>(() => getSession());
|
||||
const { iptvEnabled } = useRuntimeFeatures();
|
||||
const siteIconSrc = useSiteIcon();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
// Navigate to root to clear search query params
|
||||
window.location.href = '/';
|
||||
fetch('/api/auth/session', { method: 'DELETE' })
|
||||
.catch(() => {
|
||||
// Best effort only.
|
||||
})
|
||||
.finally(() => {
|
||||
clearSession();
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -46,10 +52,11 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
|
||||
<Image
|
||||
src="/icon.png"
|
||||
src={siteIconSrc}
|
||||
alt={siteConfig.name}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
@@ -61,7 +68,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
|
||||
{hasPermission('iptv_access') && (
|
||||
{iptvEnabled && hasPermission('iptv_access') && (
|
||||
<Link
|
||||
href="/iptv"
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
||||
@@ -75,15 +82,15 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
|
||||
{/* User Info */}
|
||||
{session && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-xs">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-xs">
|
||||
<div className="w-5 h-5 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-[10px] border border-[var(--glass-border)]">
|
||||
{session.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span>
|
||||
{session.role === 'admin' && (
|
||||
{(session.role === 'admin' || session.role === 'super_admin') && (
|
||||
<span className="px-1 py-0.5 bg-[var(--accent-color)]/10 text-[var(--accent-color)] rounded text-[10px] font-medium">
|
||||
管理
|
||||
{session.role === 'super_admin' ? '超管' : '管理'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -106,6 +113,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
>
|
||||
<Icons.Github size={20} />
|
||||
</a>
|
||||
<Link
|
||||
href={favoritesHref}
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
||||
aria-label="我的收藏"
|
||||
data-focusable
|
||||
>
|
||||
<Icons.Heart size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
href={settingsHref}
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import type { DanmakuComment } from '@/lib/types/danmaku';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import {
|
||||
clampDanmakuY,
|
||||
haveDanmakuCanvasMetricsChanged,
|
||||
resolveDanmakuCanvasMetrics,
|
||||
scaleDanmakuCoordinate,
|
||||
type DanmakuCanvasMetrics,
|
||||
} from '@/lib/player/danmaku-canvas-utils';
|
||||
|
||||
interface DanmakuCanvasProps {
|
||||
comments: DanmakuComment[];
|
||||
@@ -12,7 +19,7 @@ interface DanmakuCanvasProps {
|
||||
}
|
||||
|
||||
interface ActiveDanmaku {
|
||||
comment: DanmakuComment;
|
||||
comment: DanmakuComment & { _expiry?: number };
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
@@ -24,8 +31,58 @@ const SCROLL_DURATION = 8; // seconds for a comment to cross the screen
|
||||
const LANE_HEIGHT_FACTOR = 1.4; // multiplied by font size for lane height
|
||||
const TOP_BOTTOM_DURATION = 4; // seconds for top/bottom comments to stay visible
|
||||
const MAX_LANES = 20;
|
||||
const DEFAULT_DANMAKU_SETTINGS_SNAPSHOT = '0.7|20|0.5';
|
||||
|
||||
export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: DanmakuCanvasProps) {
|
||||
interface DanmakuSettingsSnapshot {
|
||||
opacity: number;
|
||||
fontSize: number;
|
||||
displayArea: number;
|
||||
}
|
||||
|
||||
function readCssPixelValue(value: string): number | undefined {
|
||||
if (!value.endsWith('px')) return undefined;
|
||||
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readCanvasMetrics(canvas: HTMLCanvasElement): DanmakuCanvasMetrics | null {
|
||||
const style = window.getComputedStyle(canvas);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
return resolveDanmakuCanvasMetrics({
|
||||
computedWidth: readCssPixelValue(style.width),
|
||||
computedHeight: readCssPixelValue(style.height),
|
||||
clientWidth: canvas.clientWidth,
|
||||
clientHeight: canvas.clientHeight,
|
||||
offsetWidth: canvas.offsetWidth,
|
||||
offsetHeight: canvas.offsetHeight,
|
||||
boundingWidth: rect.width,
|
||||
boundingHeight: rect.height,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
});
|
||||
}
|
||||
|
||||
function getDanmakuSettingsSnapshot(): string {
|
||||
const settings = settingsStore.getSettings();
|
||||
return `${settings.danmakuOpacity}|${settings.danmakuFontSize}|${settings.danmakuDisplayArea}`;
|
||||
}
|
||||
|
||||
function subscribeToDanmakuSettings(listener: () => void): () => void {
|
||||
return settingsStore.subscribe(listener);
|
||||
}
|
||||
|
||||
function parseDanmakuSettingsSnapshot(snapshot: string): DanmakuSettingsSnapshot {
|
||||
const [rawOpacity, rawFontSize, rawDisplayArea] = snapshot.split('|').map(Number);
|
||||
|
||||
return {
|
||||
opacity: Number.isFinite(rawOpacity) ? rawOpacity : 0.7,
|
||||
fontSize: Number.isFinite(rawFontSize) ? rawFontSize : 20,
|
||||
displayArea: Number.isFinite(rawDisplayArea) ? rawDisplayArea : 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
export function DanmakuCanvas({ comments, currentTime, isPlaying }: DanmakuCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const activeRef = useRef<ActiveDanmaku[]>([]);
|
||||
const lastTimeRef = useRef(currentTime);
|
||||
@@ -33,43 +90,127 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
const rafRef = useRef<number>(0);
|
||||
const lastSpawnTimeRef = useRef(-1);
|
||||
const laneSlotsRef = useRef<number[]>(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free
|
||||
const metricsRef = useRef<DanmakuCanvasMetrics | null>(null);
|
||||
|
||||
// Settings (read reactively)
|
||||
const [opacity, setOpacity] = React.useState(0.7);
|
||||
const [fontSize, setFontSize] = React.useState(20);
|
||||
const [displayArea, setDisplayArea] = React.useState(0.5);
|
||||
const settingsSnapshot = React.useSyncExternalStore(
|
||||
subscribeToDanmakuSettings,
|
||||
getDanmakuSettingsSnapshot,
|
||||
() => DEFAULT_DANMAKU_SETTINGS_SNAPSHOT,
|
||||
);
|
||||
const { opacity, fontSize, displayArea } = React.useMemo(
|
||||
() => parseDanmakuSettingsSnapshot(settingsSnapshot),
|
||||
[settingsSnapshot],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const s = settingsStore.getSettings();
|
||||
setOpacity(s.danmakuOpacity);
|
||||
setFontSize(s.danmakuFontSize);
|
||||
setDisplayArea(s.danmakuDisplayArea);
|
||||
const unsub = settingsStore.subscribe(() => {
|
||||
const ns = settingsStore.getSettings();
|
||||
setOpacity(ns.danmakuOpacity);
|
||||
setFontSize(ns.danmakuFontSize);
|
||||
setDisplayArea(ns.danmakuDisplayArea);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
const syncCanvasSize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
|
||||
// Handle canvas resize
|
||||
const next = readCanvasMetrics(canvas);
|
||||
if (!next) return null;
|
||||
|
||||
const previous = metricsRef.current;
|
||||
if (!haveDanmakuCanvasMetricsChanged(previous, next)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
canvas.width = next.bitmapWidth;
|
||||
canvas.height = next.bitmapHeight;
|
||||
|
||||
const dimensionsChanged = Boolean(
|
||||
previous &&
|
||||
(Math.abs(previous.width - next.width) > 0.5 || Math.abs(previous.height - next.height) > 0.5)
|
||||
);
|
||||
|
||||
if (previous && dimensionsChanged) {
|
||||
const effectiveHeight = next.height * displayArea;
|
||||
|
||||
activeRef.current = activeRef.current.map((danmaku) => {
|
||||
const type = danmaku.comment.type || 'scroll';
|
||||
const y = clampDanmakuY(
|
||||
scaleDanmakuCoordinate(danmaku.y, previous.height, next.height),
|
||||
fontSize,
|
||||
effectiveHeight,
|
||||
);
|
||||
|
||||
if (type === 'scroll') {
|
||||
return {
|
||||
...danmaku,
|
||||
x: scaleDanmakuCoordinate(danmaku.x, previous.width, next.width),
|
||||
y,
|
||||
speed: (next.width + danmaku.width) / SCROLL_DURATION,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...danmaku,
|
||||
x: (next.width - danmaku.width) / 2,
|
||||
y,
|
||||
};
|
||||
});
|
||||
|
||||
laneSlotsRef.current = new Array(MAX_LANES).fill(0);
|
||||
}
|
||||
|
||||
metricsRef.current = next;
|
||||
return next;
|
||||
}, [displayArea, fontSize]);
|
||||
|
||||
// Handle canvas resize. Use layout dimensions rather than transformed bounding
|
||||
// dimensions, otherwise CSS-rotated iOS fullscreen can stretch the canvas.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
let rafId: number | null = null;
|
||||
let timeoutIds: number[] = [];
|
||||
|
||||
const clearScheduledResize = () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
|
||||
for (const timeoutId of timeoutIds) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutIds = [];
|
||||
};
|
||||
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const scheduleResize = () => {
|
||||
syncCanvasSize();
|
||||
clearScheduledResize();
|
||||
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
syncCanvasSize();
|
||||
});
|
||||
timeoutIds = [
|
||||
window.setTimeout(syncCanvasSize, 120),
|
||||
window.setTimeout(syncCanvasSize, 360),
|
||||
];
|
||||
};
|
||||
|
||||
scheduleResize();
|
||||
|
||||
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleResize) : null;
|
||||
observer?.observe(canvas);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
window.addEventListener('resize', scheduleResize);
|
||||
window.addEventListener('orientationchange', scheduleResize);
|
||||
visualViewport?.addEventListener('resize', scheduleResize);
|
||||
visualViewport?.addEventListener('scroll', scheduleResize);
|
||||
|
||||
return () => {
|
||||
clearScheduledResize();
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', scheduleResize);
|
||||
window.removeEventListener('orientationchange', scheduleResize);
|
||||
visualViewport?.removeEventListener('resize', scheduleResize);
|
||||
visualViewport?.removeEventListener('scroll', scheduleResize);
|
||||
};
|
||||
}, [syncCanvasSize]);
|
||||
|
||||
// Clear on seek (when currentTime jumps significantly)
|
||||
useEffect(() => {
|
||||
@@ -88,9 +229,11 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !comments.length) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = rect.width;
|
||||
const effectiveHeight = rect.height * displayArea;
|
||||
const metrics = metricsRef.current ?? syncCanvasSize();
|
||||
if (!metrics) return;
|
||||
|
||||
const canvasWidth = metrics.width;
|
||||
const effectiveHeight = metrics.height * displayArea;
|
||||
const laneHeight = fontSize * LANE_HEIGHT_FACTOR;
|
||||
|
||||
// Find comments in the time window [lastSpawn, time]
|
||||
@@ -164,7 +307,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
: effectiveHeight - bestLane * laneHeight - fontSize * 0.4;
|
||||
|
||||
activeRef.current.push({
|
||||
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any,
|
||||
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION },
|
||||
x: (canvasWidth - textWidth) / 2,
|
||||
y,
|
||||
speed: 0,
|
||||
@@ -175,7 +318,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
}
|
||||
|
||||
lastSpawnTimeRef.current = windowEnd;
|
||||
}, [comments, fontSize, displayArea]);
|
||||
}, [comments, displayArea, fontSize, syncCanvasSize]);
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
@@ -186,17 +329,18 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const metrics = metricsRef.current ?? syncCanvasSize();
|
||||
if (!metrics) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (!isPlaying) {
|
||||
// When paused, still draw active comments frozen in place
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.scale(metrics.dpr, metrics.dpr);
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
@@ -235,7 +379,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
}
|
||||
} else {
|
||||
// Top/bottom: remove when expired
|
||||
const expiry = (d.comment as any)._expiry || 0;
|
||||
const expiry = d.comment._expiry || 0;
|
||||
if (currentTime < expiry) {
|
||||
newActive.push(d);
|
||||
}
|
||||
@@ -245,7 +389,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
|
||||
// Draw
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.scale(metrics.dpr, metrics.dpr);
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
@@ -268,7 +412,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
lastRafTimeRef.current = 0;
|
||||
};
|
||||
}, [isPlaying, currentTime, opacity, fontSize, spawnComments]);
|
||||
}, [isPlaying, currentTime, opacity, fontSize, spawnComments, syncCanvasSize]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useIsIOS, useIsMobile } from '@/lib/hooks/mobile/useDeviceDetection';
|
||||
import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap';
|
||||
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
|
||||
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||
import { shouldHidePlayerCursor } from '@/lib/player/cursor-visibility';
|
||||
import './web-fullscreen.css';
|
||||
|
||||
type WebFullscreenSize = 'full' | 'large' | 'focused';
|
||||
@@ -312,9 +313,22 @@ export function DesktopVideoPlayer({
|
||||
};
|
||||
}, [data.fullscreenMode, shouldForceLandscape, viewportMetrics, webFullscreenSize]);
|
||||
|
||||
const shouldHideCursor = shouldHidePlayerCursor({
|
||||
isFullscreen: data.isFullscreen,
|
||||
isPlaying: data.isPlaying,
|
||||
showControls: data.showControls,
|
||||
hasInteractiveOverlay: data.showSpeedMenu || data.showMoreMenu || data.showVolumeBar,
|
||||
});
|
||||
|
||||
const containerStyle = React.useMemo<React.CSSProperties>(() => ({
|
||||
...(webFullscreenStyle ?? {}),
|
||||
cursor: shouldHideCursor ? 'none' : undefined,
|
||||
}), [webFullscreenStyle, shouldHideCursor]);
|
||||
|
||||
const stageClassName = data.fullscreenMode === 'window'
|
||||
? 'kvideo-stage kvideo-web-fullscreen-stage'
|
||||
: 'kvideo-stage absolute inset-0';
|
||||
const isTopAlignedWebFullscreen = data.fullscreenMode === 'window' && isMobile && !isLandscape && !shouldForceLandscape;
|
||||
|
||||
// Mobile double-tap gesture for skip forward/backward
|
||||
const { handleTap } = useDoubleTap({
|
||||
@@ -341,15 +355,15 @@ export function DesktopVideoPlayer({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.fullscreenMode === 'window' ? 'is-web-fullscreen' : ''
|
||||
} ${shouldForceLandscape ? 'force-landscape' : ''}`}
|
||||
style={webFullscreenStyle}
|
||||
className={`kvideo-container relative aspect-video bg-black group ${data.fullscreenMode === 'window' ? 'is-web-fullscreen' : ''
|
||||
} ${shouldForceLandscape ? 'force-landscape' : ''} ${isTopAlignedWebFullscreen ? 'top-align-stage' : ''} overflow-hidden rounded-none sm:rounded-[var(--radius-2xl)]`}
|
||||
style={containerStyle}
|
||||
onMouseMove={() => { handleMouseMove(); }}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
<div className={stageClassName}>
|
||||
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */}
|
||||
<div className={`absolute inset-0 overflow-hidden pointer-events-none ${data.fullscreenMode === 'window' ? 'rounded-0' : 'rounded-[var(--radius-2xl)]'
|
||||
<div className={`absolute inset-0 overflow-hidden pointer-events-none ${data.fullscreenMode === 'window' ? 'rounded-none' : 'rounded-none sm:rounded-[var(--radius-2xl)]'
|
||||
}`}>
|
||||
<div className="absolute inset-0 pointer-events-auto">
|
||||
{/* Video Element */}
|
||||
|
||||
+272
-160
@@ -9,9 +9,10 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { extractQualityLabel } from '@/lib/utils/video';
|
||||
import type { VideoResolutionInfo } from './hooks/useVideoResolution';
|
||||
import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe';
|
||||
import { getCachedResolution } from '@/lib/player/resolution-cache';
|
||||
import { getSourceResolutionBadge, shouldExpandForCurrentSource } from '@/lib/player/source-list-utils';
|
||||
|
||||
interface Episode {
|
||||
name?: string;
|
||||
@@ -66,8 +67,14 @@ export function EpisodeList({
|
||||
}: EpisodeListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const [sourceExpanded, setSourceExpanded] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
// list = classic vertical list; grid = multi-column with section pages
|
||||
const [episodeLayout, setEpisodeLayout] = useState<'list' | 'grid'>('grid');
|
||||
const [episodePage, setEpisodePage] = useState(0);
|
||||
|
||||
const EPISODES_PER_PAGE = 50;
|
||||
|
||||
// Source latency state
|
||||
const [latencies, setLatencies] = useState<Record<string, number>>({});
|
||||
@@ -77,18 +84,14 @@ export function EpisodeList({
|
||||
|
||||
// Helper: get best resolution badge for a source
|
||||
const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => {
|
||||
// For current source, prefer actual detected resolution from video element
|
||||
if (isCurrent && currentResolution) {
|
||||
return { label: currentResolution.label, color: currentResolution.color };
|
||||
}
|
||||
// Check probed resolution from m3u8 manifest
|
||||
const probeKey = `${source.source}:${source.id}`;
|
||||
const probed = sourceResolutions?.[probeKey];
|
||||
if (probed) {
|
||||
return { label: probed.label, color: probed.color };
|
||||
}
|
||||
// Fall back to quality label parsed from remarks
|
||||
return extractQualityLabel(source.remarks) || null;
|
||||
return getSourceResolutionBadge({
|
||||
isCurrent,
|
||||
currentResolution: currentResolution || undefined,
|
||||
probedResolution: sourceResolutions?.[probeKey] || undefined,
|
||||
cachedResolution: getCachedResolution(source.source, source.id) || undefined,
|
||||
remarks: source.remarks,
|
||||
});
|
||||
}, [currentResolution, sourceResolutions]);
|
||||
|
||||
// Current source info
|
||||
@@ -97,21 +100,47 @@ export function EpisodeList({
|
||||
return sources.find(s => s.source === currentSource) || null;
|
||||
}, [sources, currentSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceSectionCollapsed) {
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}, [sourceSectionCollapsed]);
|
||||
|
||||
// Sort sources by latency
|
||||
const initialLatencies = useMemo(() => {
|
||||
if (!sources) return {};
|
||||
return sources.reduce<Record<string, number>>((accumulator, source) => {
|
||||
if (source.latency !== undefined) {
|
||||
accumulator[source.source] = source.latency;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [sources]);
|
||||
|
||||
const mergedLatencies = useMemo(() => ({
|
||||
...initialLatencies,
|
||||
...latencies,
|
||||
}), [initialLatencies, latencies]);
|
||||
|
||||
const sortedSources = useMemo(() => {
|
||||
if (!sources) return [];
|
||||
return [...sources].sort((a, b) => {
|
||||
const latA = latencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = latencies[b.source] ?? b.latency ?? Infinity;
|
||||
const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
});
|
||||
}, [sources, latencies]);
|
||||
}, [mergedLatencies, sources]);
|
||||
|
||||
const isSourceListOpen = !sourceSectionCollapsed && sourceExpanded;
|
||||
const forceExpandedForCurrentSource = !!currentSource && shouldExpandForCurrentSource(sortedSources, currentSource);
|
||||
const showAllVisibleSources = showAllSources || forceExpandedForCurrentSource;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSourceListOpen || !currentSource) return;
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
sourceItemRefs.current[currentSource]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [currentSource, isSourceListOpen, showAllVisibleSources, sortedSources]);
|
||||
|
||||
// Resolve source ID to its actual baseUrl for pinging
|
||||
const getSourcePingUrl = useCallback((sourceId: string): string | null => {
|
||||
@@ -127,16 +156,7 @@ export function EpisodeList({
|
||||
// Initialize latencies from sources
|
||||
useEffect(() => {
|
||||
if (!sources) return;
|
||||
const initial: Record<string, number> = {};
|
||||
let hasMissing = false;
|
||||
sources.forEach(s => {
|
||||
if (s.latency !== undefined) {
|
||||
initial[s.source] = s.latency;
|
||||
} else {
|
||||
hasMissing = true;
|
||||
}
|
||||
});
|
||||
setLatencies(initial);
|
||||
const hasMissing = sources.some((source) => source.latency === undefined);
|
||||
|
||||
// Auto-refresh latencies for sources that don't have them
|
||||
if (hasMissing && sources.length > 1) {
|
||||
@@ -214,6 +234,46 @@ export function EpisodeList({
|
||||
return isReversed ? [...episodes].reverse() : episodes;
|
||||
}, [episodes, isReversed]);
|
||||
|
||||
const totalEpisodePages = useMemo(() => {
|
||||
if (!displayEpisodes || displayEpisodes.length === 0) return 1;
|
||||
return Math.max(1, Math.ceil(displayEpisodes.length / EPISODES_PER_PAGE));
|
||||
}, [displayEpisodes]);
|
||||
|
||||
// Keep the current episode's page visible when order/layout changes
|
||||
useEffect(() => {
|
||||
if (!episodes || episodes.length === 0) {
|
||||
setEpisodePage(0);
|
||||
return;
|
||||
}
|
||||
const displayIndex = isReversed
|
||||
? episodes.length - 1 - currentEpisode
|
||||
: currentEpisode;
|
||||
const page = Math.floor(displayIndex / EPISODES_PER_PAGE);
|
||||
setEpisodePage(Math.min(Math.max(0, page), Math.max(0, Math.ceil(episodes.length / EPISODES_PER_PAGE) - 1)));
|
||||
}, [currentEpisode, episodes, isReversed, episodeLayout]);
|
||||
|
||||
const pagedEpisodes = useMemo(() => {
|
||||
if (!displayEpisodes) return null;
|
||||
if (episodeLayout === 'list' || displayEpisodes.length <= EPISODES_PER_PAGE) {
|
||||
return displayEpisodes.map((episode, displayIndex) => ({ episode, displayIndex }));
|
||||
}
|
||||
const start = episodePage * EPISODES_PER_PAGE;
|
||||
return displayEpisodes
|
||||
.slice(start, start + EPISODES_PER_PAGE)
|
||||
.map((episode, offset) => ({ episode, displayIndex: start + offset }));
|
||||
}, [displayEpisodes, episodeLayout, episodePage]);
|
||||
|
||||
const pageRangeLabels = useMemo(() => {
|
||||
if (!displayEpisodes) return [] as string[];
|
||||
const labels: string[] = [];
|
||||
for (let page = 0; page < totalEpisodePages; page++) {
|
||||
const start = page * EPISODES_PER_PAGE + 1;
|
||||
const end = Math.min((page + 1) * EPISODES_PER_PAGE, displayEpisodes.length);
|
||||
labels.push(`${start}-${end}`);
|
||||
}
|
||||
return labels;
|
||||
}, [displayEpisodes, totalEpisodePages]);
|
||||
|
||||
// Map display index to original index
|
||||
const getOriginalIndex = useCallback((displayIndex: number) => {
|
||||
if (!episodes || !isReversed) return displayIndex;
|
||||
@@ -283,7 +343,7 @@ export function EpisodeList({
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!sourceSectionCollapsed) {
|
||||
setSourceExpanded(!sourceExpanded);
|
||||
setSourceExpanded((current) => !current);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 flex items-center justify-between gap-3 text-left ${sourceSectionCollapsed ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
@@ -301,7 +361,7 @@ export function EpisodeList({
|
||||
{!sourceSectionCollapsed && (
|
||||
<Icons.ChevronDown
|
||||
size={16}
|
||||
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${sourceExpanded ? 'rotate-180' : 'rotate-0'}`}
|
||||
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${isSourceListOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@@ -331,11 +391,11 @@ export function EpisodeList({
|
||||
</div>
|
||||
|
||||
{/* Expanded source list */}
|
||||
{!sourceSectionCollapsed && sourceExpanded && (
|
||||
{isSourceListOpen && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{(() => {
|
||||
const MAX_VISIBLE = 5;
|
||||
const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const visibleSources = showAllVisibleSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const hasMoreSources = sortedSources.length > MAX_VISIBLE;
|
||||
|
||||
// Group sources by typeName
|
||||
@@ -360,12 +420,14 @@ export function EpisodeList({
|
||||
)}
|
||||
{typeSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
const globalIndex = sortedSources.indexOf(source);
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
@@ -382,35 +444,33 @@ export function EpisodeList({
|
||||
`}
|
||||
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="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 || '/placeholder-poster.svg'}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
if (target.dataset.fallback === '1') return;
|
||||
target.dataset.fallback = '1';
|
||||
target.src = '/placeholder-poster.svg';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
||||
{source.sourceName || source.source}
|
||||
{(() => {
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
return badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{source.remarks && !extractQualityLabel(source.remarks) && (
|
||||
{source.remarks && !badge && (
|
||||
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
||||
)}
|
||||
{latency !== undefined && (
|
||||
@@ -441,11 +501,13 @@ export function EpisodeList({
|
||||
) : (
|
||||
visibleSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
@@ -462,35 +524,33 @@ export function EpisodeList({
|
||||
`}
|
||||
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="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 || '/placeholder-poster.svg'}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
if (target.dataset.fallback === '1') return;
|
||||
target.dataset.fallback = '1';
|
||||
target.src = '/placeholder-poster.svg';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
||||
{source.sourceName || source.source}
|
||||
{(() => {
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
return badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{source.remarks && !extractQualityLabel(source.remarks) && (
|
||||
{source.remarks && !badge && (
|
||||
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
||||
)}
|
||||
{latency !== undefined && (
|
||||
@@ -520,10 +580,10 @@ export function EpisodeList({
|
||||
</div>
|
||||
{hasMoreSources && (
|
||||
<button
|
||||
onClick={() => setShowAllSources(!showAllSources)}
|
||||
onClick={() => setShowAllSources((current) => !current)}
|
||||
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 ? (
|
||||
{showAllVisibleSources ? (
|
||||
<>收起 <Icons.ChevronDown size={12} className="rotate-180" /></>
|
||||
) : (
|
||||
<>展开更多 ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
|
||||
@@ -538,40 +598,59 @@ export function EpisodeList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<div className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2 flex-wrap">
|
||||
<Icons.List size={20} className="sm:w-6 sm:h-6" />
|
||||
<span>选集</span>
|
||||
{episodes && (
|
||||
<Badge variant="primary">{episodes.length}</Badge>
|
||||
)}
|
||||
{/* Reverse order toggle button - only show when more than 1 episode */}
|
||||
{showReverseToggle && !episodeSectionCollapsed && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{/* Layout toggle */}
|
||||
{showReverseToggle && !episodeSectionCollapsed && (
|
||||
<button
|
||||
onClick={() => setEpisodeLayout((current) => (current === 'grid' ? 'list' : 'grid'))}
|
||||
className={`
|
||||
p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 cursor-pointer
|
||||
${episodeLayout === 'grid'
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
||||
}
|
||||
`}
|
||||
aria-label={episodeLayout === 'grid' ? '切换为列表' : '切换为网格'}
|
||||
title={episodeLayout === 'grid' ? '切换为列表' : '切换为网格'}
|
||||
>
|
||||
<Icons.Layers size={16} />
|
||||
</button>
|
||||
)}
|
||||
{/* Reverse order toggle button - only show when more than 1 episode */}
|
||||
{showReverseToggle && !episodeSectionCollapsed && (
|
||||
<button
|
||||
onClick={() => onToggleReverse?.(!isReversed)}
|
||||
className={`
|
||||
p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 cursor-pointer
|
||||
${isReversed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
||||
}
|
||||
`}
|
||||
aria-label={isReversed ? '恢复正序' : '倒序排列'}
|
||||
title={isReversed ? '恢复正序' : '倒序排列'}
|
||||
>
|
||||
<Icons.ArrowUpDown size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleReverse?.(!isReversed)}
|
||||
className={`
|
||||
ml-auto p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200
|
||||
${isReversed
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
||||
}
|
||||
`}
|
||||
aria-label={isReversed ? '恢复正序' : '倒序排列'}
|
||||
title={isReversed ? '恢复正序' : '倒序排列'}
|
||||
onClick={() => onEpisodeSectionCollapseChange?.(!episodeSectionCollapsed)}
|
||||
className="p-1.5 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)] transition-all duration-200 cursor-pointer"
|
||||
aria-label={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
|
||||
title={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
|
||||
>
|
||||
<Icons.ArrowUpDown size={16} />
|
||||
<Icons.ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${episodeSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onEpisodeSectionCollapseChange?.(!episodeSectionCollapsed)}
|
||||
className="p-1.5 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)] transition-all duration-200 cursor-pointer"
|
||||
aria-label={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
|
||||
title={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
|
||||
>
|
||||
<Icons.ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${episodeSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{episodeSectionCollapsed ? (
|
||||
@@ -584,59 +663,92 @@ export function EpisodeList({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2"
|
||||
role="radiogroup"
|
||||
aria-label="剧集选择"
|
||||
>
|
||||
{displayEpisodes && displayEpisodes.length > 0 ? (
|
||||
displayEpisodes.map((episode, displayIndex) => {
|
||||
const originalIndex = getOriginalIndex(displayIndex);
|
||||
const isCurrentEpisode = currentEpisode === originalIndex;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Section page chips for long episode lists */}
|
||||
{episodeLayout === 'grid' && totalEpisodePages > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pageRangeLabels.map((label, page) => (
|
||||
<button
|
||||
key={originalIndex}
|
||||
ref={(el) => { buttonRefs.current[displayIndex] = el; }}
|
||||
onClick={() => onEpisodeClick(episode, originalIndex)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEpisodeClick(episode, originalIndex);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="radio"
|
||||
aria-checked={isCurrentEpisode}
|
||||
aria-current={isCurrentEpisode ? 'true' : undefined}
|
||||
aria-label={`${episode.name || `第 ${originalIndex + 1} 集`}${isCurrentEpisode ? ',当前播放' : ''}`}
|
||||
key={label}
|
||||
onClick={() => setEpisodePage(page)}
|
||||
className={`
|
||||
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
|
||||
${isCurrentEpisode
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
px-2.5 py-1 rounded-[var(--radius-2xl)] text-xs font-medium transition-all duration-200 cursor-pointer
|
||||
${episodePage === page
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
||||
}
|
||||
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
|
||||
`}
|
||||
aria-current={episodePage === page ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm sm:text-base">
|
||||
{episode.name || `第 ${originalIndex + 1} 集`}
|
||||
</span>
|
||||
{isCurrentEpisode && (
|
||||
<Icons.Play size={16} />
|
||||
)}
|
||||
</div>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
|
||||
<p>暂无剧集信息</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`max-h-[400px] sm:max-h-[600px] overflow-y-auto pr-1 ${
|
||||
episodeLayout === 'grid'
|
||||
? 'grid grid-cols-3 sm:grid-cols-4 gap-2'
|
||||
: 'space-y-2'
|
||||
}`}
|
||||
role="radiogroup"
|
||||
aria-label="剧集选择"
|
||||
>
|
||||
{pagedEpisodes && pagedEpisodes.length > 0 ? (
|
||||
pagedEpisodes.map(({ episode, displayIndex }) => {
|
||||
const originalIndex = getOriginalIndex(displayIndex);
|
||||
const isCurrentEpisode = currentEpisode === originalIndex;
|
||||
const isGrid = episodeLayout === 'grid';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={originalIndex}
|
||||
ref={(el) => { buttonRefs.current[displayIndex] = el; }}
|
||||
onClick={() => onEpisodeClick(episode, originalIndex)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEpisodeClick(episode, originalIndex);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="radio"
|
||||
aria-checked={isCurrentEpisode}
|
||||
aria-current={isCurrentEpisode ? 'true' : undefined}
|
||||
aria-label={`${episode.name || `第 ${originalIndex + 1} 集`}${isCurrentEpisode ? ',当前播放' : ''}`}
|
||||
className={`
|
||||
rounded-[var(--radius-2xl)] transition-[var(--transition-fluid)] cursor-pointer
|
||||
${isGrid
|
||||
? 'px-2 py-2.5 text-center'
|
||||
: 'w-full px-3 py-2 sm:px-4 sm:py-3 text-left'
|
||||
}
|
||||
${isCurrentEpisode
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
}
|
||||
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
|
||||
`}
|
||||
>
|
||||
<div className={`flex items-center ${isGrid ? 'justify-center gap-1' : 'justify-between'}`}>
|
||||
<span className={`font-medium ${isGrid ? 'text-xs sm:text-sm truncate' : 'text-sm sm:text-base'}`}>
|
||||
{episode.name || `第 ${originalIndex + 1} 集`}
|
||||
</span>
|
||||
{isCurrentEpisode && !isGrid && (
|
||||
<Icons.Play size={16} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)] col-span-full">
|
||||
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
|
||||
<p>暂无剧集信息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -3,11 +3,13 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { useSiteIcon } from '@/components/SiteIconProvider';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { siteConfig } from '@/lib/config/site-config';
|
||||
|
||||
export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
|
||||
const router = useRouter();
|
||||
const siteIconSrc = useSiteIcon();
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4" style={{ transform: 'translateZ(0)' }}>
|
||||
@@ -20,10 +22,11 @@ export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
|
||||
title={isPremium ? "返回高级主页" : "返回首页"}
|
||||
>
|
||||
<Image
|
||||
src="/icon.png"
|
||||
src={siteIconSrc}
|
||||
alt={siteConfig.name}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -24,13 +24,31 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
||||
return (
|
||||
<Card hover={false}>
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||
{videoData?.vod_pic && (
|
||||
<img
|
||||
src={videoData.vod_pic}
|
||||
alt={videoData.vod_name}
|
||||
className="w-24 h-36 sm:w-32 sm:h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
|
||||
/>
|
||||
)}
|
||||
<div className="w-24 h-36 sm:w-32 sm:h-48 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] overflow-hidden bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] flex-shrink-0">
|
||||
{videoData?.vod_pic ? (
|
||||
<img
|
||||
src={videoData.vod_pic}
|
||||
alt={videoData.vod_name || title || ''}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallback === '1') {
|
||||
target.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
target.dataset.fallback = '1';
|
||||
target.src = '/placeholder-poster.svg';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/placeholder-poster.svg"
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-[var(--text-color)] mb-3">
|
||||
{videoData?.vod_name || title}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
import { VideoPlayerError } from './VideoPlayerError';
|
||||
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
|
||||
import { usePlayerSettings } from './hooks/usePlayerSettings';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
playUrl: string;
|
||||
@@ -53,38 +51,8 @@ export function VideoPlayer({
|
||||
const durationRef = useRef(0);
|
||||
const SAVE_INTERVAL = 5000; // 5 seconds throttle
|
||||
|
||||
// Get showModeIndicator setting
|
||||
// Get showModeIndicator and proxyMode settings
|
||||
const [showModeIndicator, setShowModeIndicator] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
|
||||
|
||||
useEffect(() => {
|
||||
// Initial value - use mode-specific store
|
||||
const store = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
const settings = store.getSettings();
|
||||
setShowModeIndicator(settings.showModeIndicator);
|
||||
setProxyMode(settings.proxyMode);
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
const newSettings = store.getSettings();
|
||||
setShowModeIndicator(newSettings.showModeIndicator);
|
||||
setProxyMode(newSettings.proxyMode);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Initialize useProxy based on proxyMode when the component mounts or proxyMode changes
|
||||
// We use a separate effect for this to react to setting changes
|
||||
useEffect(() => {
|
||||
if (proxyMode === 'always') {
|
||||
setUseProxy(true);
|
||||
} else if (proxyMode === 'none') {
|
||||
setUseProxy(false);
|
||||
}
|
||||
// For 'retry', we assume it starts as false (direct), which is the default state of useProxy
|
||||
}, [proxyMode]);
|
||||
const { showModeIndicator, proxyMode } = usePlayerSettings(isPremium);
|
||||
const effectiveUseProxy = proxyMode === 'always' ? true : proxyMode === 'none' ? false : useProxy;
|
||||
|
||||
|
||||
// Use reactive hook to subscribe to history updates
|
||||
@@ -174,7 +142,7 @@ export function VideoPlayer({
|
||||
// 3. Proxy mode is 'retry' (specifically for the auto-switch logic)
|
||||
// Note: If mode is 'always', we are already using proxy. If it fails, we show error.
|
||||
|
||||
if (!useProxy && proxyMode === 'retry') {
|
||||
if (!effectiveUseProxy && proxyMode === 'retry') {
|
||||
setUseProxy(true);
|
||||
setShouldAutoPlay(true); // Force autoplay after proxy retry
|
||||
setVideoError('');
|
||||
@@ -200,10 +168,10 @@ export function VideoPlayer({
|
||||
// Let's toggle it to give best chance.
|
||||
// Actually requirement says "try no proxy and proxy and same as before".
|
||||
// So simple toggle is fine.
|
||||
setUseProxy(prev => !prev);
|
||||
setUseProxy(prev => proxyMode === 'none' ? false : !prev);
|
||||
};
|
||||
|
||||
const finalPlayUrl = useProxy || proxyMode === 'always'
|
||||
const finalPlayUrl = effectiveUseProxy
|
||||
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
|
||||
: playUrl;
|
||||
|
||||
@@ -212,16 +180,15 @@ export function VideoPlayer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-no-spatial>
|
||||
<Card hover={false} className="p-0 relative">
|
||||
<div data-no-spatial className="relative">
|
||||
{/* Mode Indicator Badge - controlled by settings */}
|
||||
{showModeIndicator && (
|
||||
<div className="absolute top-3 right-3 z-30">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${effectiveUseProxy
|
||||
? 'bg-orange-500/80 text-white'
|
||||
: 'bg-green-500/80 text-white'
|
||||
}`}>
|
||||
{useProxy ? '代理模式' : '直连模式'}
|
||||
{effectiveUseProxy ? '代理模式' : '直连模式'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,7 +202,7 @@ export function VideoPlayer({
|
||||
/>
|
||||
) : (
|
||||
<CustomVideoPlayer
|
||||
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
|
||||
key={`${effectiveUseProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
|
||||
src={finalPlayUrl}
|
||||
onError={handleVideoError}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
@@ -251,7 +218,6 @@ export function VideoPlayer({
|
||||
onResolutionDetected={onResolutionDetected}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ export function DesktopControls(props: DesktopControlsProps) {
|
||||
/>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="player-controls-bar bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
|
||||
<div className="player-controls-row flex min-w-0 items-center justify-between gap-4">
|
||||
<DesktopLeftControls {...props} formatTime={formatTime} />
|
||||
<DesktopRightControls {...props} />
|
||||
</div>
|
||||
|
||||
@@ -32,30 +32,33 @@ export function DesktopLeftControls({
|
||||
formatTime
|
||||
}: DesktopLeftControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="player-controls-left flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="btn-icon"
|
||||
className="btn-icon shrink-0"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<DesktopVolumeControl
|
||||
volumeBarRef={volumeBarRef}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
showVolumeBar={showVolumeBar}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeMouseDown={onVolumeMouseDown}
|
||||
/>
|
||||
<div className="player-volume-control shrink-0">
|
||||
<DesktopVolumeControl
|
||||
volumeBarRef={volumeBarRef}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
showVolumeBar={showVolumeBar}
|
||||
onToggleMute={onToggleMute}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeMouseDown={onVolumeMouseDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-white text-sm font-medium tabular-nums">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
<span className="player-time-display min-w-0 truncate text-sm font-medium text-white tabular-nums">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span className="player-duration-display"> / {formatTime(duration)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -84,6 +84,7 @@ export function DesktopOverlay({
|
||||
}: DesktopOverlayProps) {
|
||||
// Show navigation buttons when controls are visible or when paused (controls usually show when paused anyway)
|
||||
const showNavButtons = showControls || !isPlaying;
|
||||
const showFullscreenClock = showControls || !isPlaying;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,7 +107,7 @@ export function DesktopOverlay({
|
||||
|
||||
{isFullscreen && fullscreenClock && (
|
||||
<div
|
||||
className={`absolute top-8 left-1/2 -translate-x-1/2 z-40 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-70'}`}
|
||||
className={`absolute top-8 left-1/2 -translate-x-1/2 z-40 transition-opacity duration-300 ${showFullscreenClock ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<div className="min-w-[88px] px-4 py-2 rounded-full bg-black/45 backdrop-blur-md border border-white/15 text-center shadow-[0_10px_30px_rgba(0,0,0,0.3)]">
|
||||
|
||||
@@ -29,13 +29,13 @@ export function DesktopRightControls({
|
||||
onShowCastMenu
|
||||
}: DesktopRightControlsProps) {
|
||||
return (
|
||||
<div className="relative z-50 flex items-center gap-3">
|
||||
<div className="player-controls-right relative z-50 flex shrink-0 items-center gap-3">
|
||||
{/* Picture-in-Picture */}
|
||||
{
|
||||
isPiPSupported && (
|
||||
<button
|
||||
onClick={onTogglePictureInPicture}
|
||||
className="btn-icon"
|
||||
className="btn-icon shrink-0"
|
||||
aria-label="画中画"
|
||||
title="画中画"
|
||||
>
|
||||
@@ -49,7 +49,7 @@ export function DesktopRightControls({
|
||||
isAirPlaySupported && (
|
||||
<button
|
||||
onClick={onShowAirPlayMenu}
|
||||
className="btn-icon"
|
||||
className="btn-icon shrink-0"
|
||||
aria-label="隔空播放"
|
||||
title="隔空播放"
|
||||
>
|
||||
@@ -63,7 +63,7 @@ export function DesktopRightControls({
|
||||
isCastAvailable && (
|
||||
<button
|
||||
onClick={onShowCastMenu}
|
||||
className="btn-icon"
|
||||
className="btn-icon shrink-0"
|
||||
aria-label="投屏"
|
||||
title="投屏"
|
||||
>
|
||||
@@ -75,17 +75,19 @@ export function DesktopRightControls({
|
||||
{/* Web Fullscreen */}
|
||||
<button
|
||||
onClick={onToggleWebFullscreen}
|
||||
className="btn-icon"
|
||||
className="btn-icon shrink-0"
|
||||
aria-label={isWebFullscreen ? '退出网页全屏' : '网页全屏'}
|
||||
title={isWebFullscreen ? '退出网页全屏 (W)' : '网页全屏 (W)'}
|
||||
>
|
||||
<Icons.Target size={20} className={isWebFullscreen ? 'text-[var(--accent-color)]' : ''} />
|
||||
{isWebFullscreen
|
||||
? <Icons.WebFullscreenExit size={20} className="text-[var(--accent-color)]" />
|
||||
: <Icons.WebFullscreen size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Native Fullscreen */}
|
||||
<button
|
||||
onClick={onToggleNativeFullscreen}
|
||||
className="btn-icon"
|
||||
className="btn-icon shrink-0"
|
||||
aria-label={isNativeFullscreen ? '退出系统全屏' : '系统全屏'}
|
||||
title={isNativeFullscreen ? '退出系统全屏 (F)' : '系统全屏 (F)'}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { FullscreenMode } from '../useDesktopPlayerState';
|
||||
|
||||
export interface AndroidPiPTransitionPlan {
|
||||
enterTemporaryWindowFullscreen: boolean;
|
||||
restoreInlineOnExit: boolean;
|
||||
}
|
||||
|
||||
export interface AndroidPiPSessionState {
|
||||
enteredTemporaryWindowFullscreen: boolean;
|
||||
restoreInlineOnExit: boolean;
|
||||
}
|
||||
|
||||
export interface AndroidPiPSourceRect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function createAndroidPiPTransitionPlan(fullscreenMode: FullscreenMode): AndroidPiPTransitionPlan {
|
||||
const enterTemporaryWindowFullscreen = fullscreenMode !== 'window';
|
||||
|
||||
return {
|
||||
enterTemporaryWindowFullscreen,
|
||||
restoreInlineOnExit: enterTemporaryWindowFullscreen,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRestoreInlineAfterAndroidPiP(
|
||||
session: AndroidPiPSessionState | null,
|
||||
inPictureInPicture: boolean
|
||||
): boolean {
|
||||
if (!session || inPictureInPicture) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.enteredTemporaryWindowFullscreen && session.restoreInlineOnExit;
|
||||
}
|
||||
|
||||
export function shouldRollbackTemporaryWindowFullscreen(session: AndroidPiPSessionState | null): boolean {
|
||||
return Boolean(session?.enteredTemporaryWindowFullscreen);
|
||||
}
|
||||
|
||||
export function getAndroidPiPSourceRect(container: HTMLElement | null): AndroidPiPSourceRect | null {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.max(0, Math.round(rect.left)),
|
||||
top: Math.max(0, Math.round(rect.top)),
|
||||
right: Math.max(0, Math.round(rect.right)),
|
||||
bottom: Math.max(0, Math.round(rect.bottom)),
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ declare global {
|
||||
interface Window {
|
||||
chrome: any;
|
||||
cast: any;
|
||||
__onGCastApiAvailable: (isAvailable: boolean) => void;
|
||||
__onGCastApiAvailable?: (isAvailable: boolean) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,69 +24,37 @@ export function useCastControls({
|
||||
setIsCasting
|
||||
}: UseCastControlsProps) {
|
||||
const castContextRef = useRef<any>(null);
|
||||
const loadMediaRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
// Function to initialize Cast
|
||||
const initializeCastApi = () => {
|
||||
if (!window.cast || !window.cast.framework) return;
|
||||
const isCastSdkReady = useCallback(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
castContextRef.current = castContext;
|
||||
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||
});
|
||||
|
||||
// SDK loaded — show cast button immediately.
|
||||
// requestSession() will re-scan and show Chrome's native device picker.
|
||||
setIsCastAvailable(true);
|
||||
|
||||
// Monitor session state
|
||||
castContext.addEventListener(
|
||||
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(event: any) => {
|
||||
const sessionState = event.sessionState;
|
||||
const isSessionActive = sessionState === window.cast.framework.SessionState.SESSION_STARTED ||
|
||||
sessionState === window.cast.framework.SessionState.SESSION_RESUMED;
|
||||
|
||||
setIsCasting(isSessionActive);
|
||||
|
||||
if (isSessionActive && videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
loadMedia();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// If API is already loaded
|
||||
if (window.cast && window.cast.framework) {
|
||||
initializeCastApi();
|
||||
} else {
|
||||
// Wait for API to be available
|
||||
window.__onGCastApiAvailable = (isAvailable: boolean) => {
|
||||
if (isAvailable) {
|
||||
initializeCastApi();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [setIsCastAvailable, setIsCasting, videoRef]);
|
||||
return Boolean(
|
||||
window.cast?.framework?.CastContext?.getInstance &&
|
||||
window.cast?.framework?.CastContextEventType?.SESSION_STATE_CHANGED &&
|
||||
window.cast?.framework?.SessionState &&
|
||||
window.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID &&
|
||||
window.chrome?.cast?.media?.MediaInfo &&
|
||||
window.chrome?.cast?.media?.LoadRequest &&
|
||||
window.chrome?.cast?.AutoJoinPolicy?.ORIGIN_SCOPED
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadMedia = useCallback(() => {
|
||||
if (!castContextRef.current || !src) return;
|
||||
const castMedia = window.chrome?.cast?.media;
|
||||
if (!castContextRef.current || !src || !castMedia?.MediaInfo || !castMedia?.LoadRequest) return;
|
||||
|
||||
const castContext = castContextRef.current;
|
||||
const session = castContext.getCurrentSession();
|
||||
if (!session) return;
|
||||
|
||||
const mediaInfo = new window.chrome.cast.media.MediaInfo(src, 'video/mp4');
|
||||
const mediaInfo = new castMedia.MediaInfo(src, 'video/mp4');
|
||||
// Handle HLS specifically if possible, though DEFAULT_MEDIA_RECEIVER supports it
|
||||
if (src.includes('.m3u8')) {
|
||||
mediaInfo.contentType = 'application/x-mpegurl';
|
||||
}
|
||||
|
||||
const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
|
||||
const request = new castMedia.LoadRequest(mediaInfo);
|
||||
|
||||
// Sync current time
|
||||
if (videoRef.current) {
|
||||
@@ -99,11 +67,108 @@ export function useCastControls({
|
||||
);
|
||||
}, [src, videoRef]);
|
||||
|
||||
const showCastMenu = useCallback(() => {
|
||||
if (window.cast && window.cast.framework) {
|
||||
window.cast.framework.CastContext.getInstance().requestSession();
|
||||
useEffect(() => {
|
||||
loadMediaRef.current = loadMedia;
|
||||
}, [loadMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
let sessionStateListener: ((event: any) => void) | null = null;
|
||||
let onGCastApiAvailable: ((isAvailable: boolean) => void) | null = null;
|
||||
|
||||
const markCastUnavailable = () => {
|
||||
castContextRef.current = null;
|
||||
setIsCastAvailable(false);
|
||||
setIsCasting(false);
|
||||
};
|
||||
|
||||
// Function to initialize Cast
|
||||
const initializeCastApi = () => {
|
||||
if (!isCastSdkReady()) {
|
||||
markCastUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
castContextRef.current = castContext;
|
||||
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||
});
|
||||
|
||||
// SDK loaded — show cast button immediately.
|
||||
// requestSession() will re-scan and show Chrome's native device picker.
|
||||
setIsCastAvailable(true);
|
||||
|
||||
// Monitor session state
|
||||
sessionStateListener = (event: any) => {
|
||||
const sessionState = event.sessionState;
|
||||
const isSessionActive = sessionState === window.cast.framework.SessionState.SESSION_STARTED ||
|
||||
sessionState === window.cast.framework.SessionState.SESSION_RESUMED;
|
||||
|
||||
setIsCasting(isSessionActive);
|
||||
|
||||
if (isSessionActive && videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
loadMediaRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
castContext.addEventListener(
|
||||
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
sessionStateListener
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Cast SDK is not usable in this browser context.', error);
|
||||
markCastUnavailable();
|
||||
}
|
||||
};
|
||||
|
||||
// If API is already loaded
|
||||
if (isCastSdkReady()) {
|
||||
initializeCastApi();
|
||||
} else {
|
||||
// Wait for API to be available
|
||||
onGCastApiAvailable = (isAvailable: boolean) => {
|
||||
if (isAvailable) {
|
||||
initializeCastApi();
|
||||
} else {
|
||||
markCastUnavailable();
|
||||
}
|
||||
};
|
||||
window.__onGCastApiAvailable = onGCastApiAvailable;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
if (onGCastApiAvailable && window.__onGCastApiAvailable === onGCastApiAvailable) {
|
||||
delete window.__onGCastApiAvailable;
|
||||
}
|
||||
|
||||
const castContext = castContextRef.current;
|
||||
if (
|
||||
sessionStateListener &&
|
||||
castContext?.removeEventListener &&
|
||||
window.cast?.framework?.CastContextEventType?.SESSION_STATE_CHANGED
|
||||
) {
|
||||
castContext.removeEventListener(
|
||||
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
sessionStateListener
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [isCastSdkReady, setIsCastAvailable, setIsCasting, videoRef]);
|
||||
|
||||
const showCastMenu = useCallback(() => {
|
||||
if (!isCastSdkReady()) return;
|
||||
|
||||
try {
|
||||
window.cast.framework.CastContext.getInstance().requestSession();
|
||||
} catch (error) {
|
||||
console.warn('Cast session request failed.', error);
|
||||
setIsCastAvailable(false);
|
||||
}
|
||||
}, [isCastSdkReady, setIsCastAvailable]);
|
||||
|
||||
const castActions = useMemo(() => ({
|
||||
showCastMenu
|
||||
|
||||
@@ -1,5 +1,76 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { FullscreenMode } from '../useDesktopPlayerState';
|
||||
import {
|
||||
createAndroidPiPTransitionPlan,
|
||||
getAndroidPiPSourceRect,
|
||||
shouldRestoreInlineAfterAndroidPiP,
|
||||
shouldRollbackTemporaryWindowFullscreen,
|
||||
type AndroidPiPSessionState,
|
||||
} from './android-pip-utils';
|
||||
|
||||
interface AndroidPiPBridge {
|
||||
isPictureInPictureSupported?: () => boolean;
|
||||
enterPictureInPicture?: (
|
||||
width: number,
|
||||
height: number,
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
interface AndroidPiPChangeDetail {
|
||||
inPictureInPicture?: boolean;
|
||||
}
|
||||
|
||||
type OrientationCapableScreen = Screen & {
|
||||
orientation?: ScreenOrientation & {
|
||||
lock?: (orientation: string) => Promise<void>;
|
||||
unlock?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type FullscreenCapableDocument = Document & {
|
||||
webkitFullscreenElement?: Element | null;
|
||||
mozFullScreenElement?: Element | null;
|
||||
msFullscreenElement?: Element | null;
|
||||
webkitExitFullscreen?: () => Promise<void>;
|
||||
mozCancelFullScreen?: () => Promise<void>;
|
||||
msExitFullscreen?: () => Promise<void>;
|
||||
pictureInPictureEnabled?: boolean;
|
||||
pictureInPictureElement?: Element | null;
|
||||
exitPictureInPicture?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type FullscreenCapableElement = HTMLDivElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
mozRequestFullScreen?: () => Promise<void>;
|
||||
msRequestFullscreen?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type PiPCapableVideoElement = HTMLVideoElement & {
|
||||
webkitEnterFullscreen?: () => void;
|
||||
webkitSupportsPresentationMode?: (mode: 'picture-in-picture') => boolean;
|
||||
webkitPresentationMode?: 'inline' | 'picture-in-picture' | string;
|
||||
webkitSetPresentationMode?: (mode: 'inline' | 'picture-in-picture') => void;
|
||||
webkitShowPlaybackTargetPicker?: () => void;
|
||||
};
|
||||
|
||||
function getFullscreenDocument(): FullscreenCapableDocument {
|
||||
return document as FullscreenCapableDocument;
|
||||
}
|
||||
|
||||
function waitForAnimationFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWindowFullscreenLayout(): Promise<void> {
|
||||
await waitForAnimationFrame();
|
||||
await waitForAnimationFrame();
|
||||
}
|
||||
|
||||
interface UseFullscreenControlsProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -26,10 +97,13 @@ export function useFullscreenControls({
|
||||
setIsAirPlaySupported,
|
||||
fullscreenType = 'native'
|
||||
}: UseFullscreenControlsProps) {
|
||||
const androidPiPSessionRef = useRef<AndroidPiPSessionState | null>(null);
|
||||
|
||||
const lockLandscape = useCallback(async () => {
|
||||
if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.lock) {
|
||||
const orientation = (window.screen as OrientationCapableScreen).orientation;
|
||||
if (orientation?.lock) {
|
||||
try {
|
||||
await (window.screen as any).orientation.lock('landscape');
|
||||
await orientation.lock('landscape');
|
||||
} catch (error) {
|
||||
console.warn('Orientation lock failed:', error);
|
||||
}
|
||||
@@ -37,46 +111,65 @@ export function useFullscreenControls({
|
||||
}, []);
|
||||
|
||||
const unlockOrientation = useCallback(() => {
|
||||
if (window.screen && (window.screen as any).orientation && (window.screen as any).orientation.unlock) {
|
||||
const orientation = (window.screen as OrientationCapableScreen).orientation;
|
||||
if (orientation?.unlock) {
|
||||
try {
|
||||
(window.screen as any).orientation.unlock();
|
||||
orientation.unlock();
|
||||
} catch {
|
||||
// Ignore unlock errors from unsupported browsers.
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getNativeFullscreenElement = useCallback(() => (
|
||||
document.fullscreenElement ||
|
||||
(document as any).webkitFullscreenElement ||
|
||||
(document as any).mozFullScreenElement ||
|
||||
(document as any).msFullscreenElement
|
||||
), []);
|
||||
const getNativeFullscreenElement = useCallback(() => {
|
||||
const fullscreenDocument = getFullscreenDocument();
|
||||
return (
|
||||
fullscreenDocument.fullscreenElement ||
|
||||
fullscreenDocument.webkitFullscreenElement ||
|
||||
fullscreenDocument.mozFullScreenElement ||
|
||||
fullscreenDocument.msFullscreenElement
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getAndroidPiPBridge = useCallback((): AndroidPiPBridge | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const bridge = (window as Window & { KVideoAndroid?: AndroidPiPBridge }).KVideoAndroid;
|
||||
if (!bridge) return null;
|
||||
|
||||
return bridge;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const hasNativePiP = 'pictureInPictureEnabled' in document;
|
||||
const hasWebkitPiP = videoRef.current && (
|
||||
'webkitSupportsPresentationMode' in (videoRef.current as any) ||
|
||||
'webkitPresentationMode' in (videoRef.current as any)
|
||||
);
|
||||
setIsPiPSupported(hasNativePiP || !!hasWebkitPiP);
|
||||
const fullscreenDocument = getFullscreenDocument();
|
||||
const video = videoRef.current as PiPCapableVideoElement | null;
|
||||
const hasNativePiP = Boolean(fullscreenDocument.pictureInPictureEnabled);
|
||||
const hasWebkitPiP = Boolean(video && (
|
||||
typeof video.webkitSupportsPresentationMode === 'function' ||
|
||||
typeof video.webkitPresentationMode === 'string'
|
||||
));
|
||||
const androidBridge = getAndroidPiPBridge();
|
||||
const hasAndroidPiPBridge = Boolean(androidBridge?.isPictureInPictureSupported?.());
|
||||
setIsPiPSupported(hasNativePiP || !!hasWebkitPiP || hasAndroidPiPBridge);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window);
|
||||
}
|
||||
}, [setIsPiPSupported, setIsAirPlaySupported, videoRef]);
|
||||
}, [getAndroidPiPBridge, setIsPiPSupported, setIsAirPlaySupported, videoRef]);
|
||||
|
||||
const exitNativeFullscreen = useCallback(async () => {
|
||||
const fullscreenDocument = getFullscreenDocument();
|
||||
|
||||
try {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if ((document as any).webkitExitFullscreen) {
|
||||
await (document as any).webkitExitFullscreen();
|
||||
} else if ((document as any).mozCancelFullScreen) {
|
||||
await (document as any).mozCancelFullScreen();
|
||||
} else if ((document as any).msExitFullscreen) {
|
||||
await (document as any).msExitFullscreen();
|
||||
if (fullscreenDocument.exitFullscreen) {
|
||||
await fullscreenDocument.exitFullscreen();
|
||||
} else if (fullscreenDocument.webkitExitFullscreen) {
|
||||
await fullscreenDocument.webkitExitFullscreen();
|
||||
} else if (fullscreenDocument.mozCancelFullScreen) {
|
||||
await fullscreenDocument.mozCancelFullScreen();
|
||||
} else if (fullscreenDocument.msExitFullscreen) {
|
||||
await fullscreenDocument.msExitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to exit fullscreen:', error);
|
||||
@@ -104,23 +197,25 @@ export function useFullscreenControls({
|
||||
}, [exitNativeFullscreen, fullscreenMode, lockLandscape, setFullscreenMode, setIsFullscreen]);
|
||||
|
||||
const enterNativeFullscreen = useCallback(async () => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current as FullscreenCapableElement | null;
|
||||
const video = videoRef.current as PiPCapableVideoElement | null;
|
||||
if (!container) return;
|
||||
|
||||
if (fullscreenMode === 'window') {
|
||||
exitWindowFullscreen();
|
||||
}
|
||||
|
||||
try {
|
||||
if (containerRef.current.requestFullscreen) {
|
||||
await containerRef.current.requestFullscreen();
|
||||
} else if ((containerRef.current as any).webkitRequestFullscreen) {
|
||||
await (containerRef.current as any).webkitRequestFullscreen();
|
||||
} else if ((containerRef.current as any).mozRequestFullScreen) {
|
||||
await (containerRef.current as any).mozRequestFullScreen();
|
||||
} else if ((containerRef.current as any).msRequestFullscreen) {
|
||||
await (containerRef.current as any).msRequestFullscreen();
|
||||
} else if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) {
|
||||
(videoRef.current as any).webkitEnterFullscreen();
|
||||
if (container.requestFullscreen) {
|
||||
await container.requestFullscreen();
|
||||
} else if (container.webkitRequestFullscreen) {
|
||||
await container.webkitRequestFullscreen();
|
||||
} else if (container.mozRequestFullScreen) {
|
||||
await container.mozRequestFullScreen();
|
||||
} else if (container.msRequestFullscreen) {
|
||||
await container.msRequestFullscreen();
|
||||
} else if (video?.webkitEnterFullscreen) {
|
||||
video.webkitEnterFullscreen();
|
||||
}
|
||||
|
||||
setFullscreenMode('native');
|
||||
@@ -128,9 +223,9 @@ export function useFullscreenControls({
|
||||
await lockLandscape();
|
||||
} catch (error) {
|
||||
console.warn('Fullscreen request failed, trying fallback:', error);
|
||||
if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) {
|
||||
if (video?.webkitEnterFullscreen) {
|
||||
try {
|
||||
(videoRef.current as any).webkitEnterFullscreen();
|
||||
video.webkitEnterFullscreen();
|
||||
setFullscreenMode('native');
|
||||
setIsFullscreen(true);
|
||||
} catch (fallbackError) {
|
||||
@@ -148,6 +243,65 @@ export function useFullscreenControls({
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
const requestAndroidPictureInPicture = useCallback(async () => {
|
||||
const bridge = getAndroidPiPBridge();
|
||||
const video = videoRef.current;
|
||||
if (!bridge || !video || typeof bridge.enterPictureInPicture !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const plan = createAndroidPiPTransitionPlan(fullscreenMode);
|
||||
const session: AndroidPiPSessionState = {
|
||||
enteredTemporaryWindowFullscreen: plan.enterTemporaryWindowFullscreen,
|
||||
restoreInlineOnExit: plan.restoreInlineOnExit,
|
||||
};
|
||||
|
||||
try {
|
||||
if (plan.enterTemporaryWindowFullscreen) {
|
||||
await enterWindowFullscreen();
|
||||
await waitForWindowFullscreenLayout();
|
||||
}
|
||||
|
||||
const width = video.videoWidth || containerRef.current?.clientWidth || 16;
|
||||
const height = video.videoHeight || containerRef.current?.clientHeight || 9;
|
||||
const sourceRect = getAndroidPiPSourceRect(containerRef.current);
|
||||
|
||||
androidPiPSessionRef.current = session;
|
||||
|
||||
const didEnterPiP = bridge.enterPictureInPicture(
|
||||
width,
|
||||
height,
|
||||
sourceRect?.left ?? 0,
|
||||
sourceRect?.top ?? 0,
|
||||
sourceRect?.right ?? 0,
|
||||
sourceRect?.bottom ?? 0
|
||||
) !== false;
|
||||
|
||||
if (!didEnterPiP) {
|
||||
if (shouldRollbackTemporaryWindowFullscreen(session)) {
|
||||
exitWindowFullscreen();
|
||||
}
|
||||
androidPiPSessionRef.current = null;
|
||||
}
|
||||
|
||||
return didEnterPiP;
|
||||
} catch (error) {
|
||||
if (shouldRollbackTemporaryWindowFullscreen(session)) {
|
||||
exitWindowFullscreen();
|
||||
}
|
||||
androidPiPSessionRef.current = null;
|
||||
console.error('Android Picture-in-Picture bridge failed:', error);
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
containerRef,
|
||||
enterWindowFullscreen,
|
||||
exitWindowFullscreen,
|
||||
fullscreenMode,
|
||||
getAndroidPiPBridge,
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
const toggleWindowFullscreen = useCallback(async () => {
|
||||
if (fullscreenMode === 'window') {
|
||||
exitWindowFullscreen();
|
||||
@@ -251,27 +405,56 @@ export function useFullscreenControls({
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [exitWindowFullscreen, fullscreenMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleAndroidPiPChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<AndroidPiPChangeDetail>).detail;
|
||||
const inPictureInPicture = Boolean(detail?.inPictureInPicture);
|
||||
|
||||
if (shouldRestoreInlineAfterAndroidPiP(androidPiPSessionRef.current, inPictureInPicture)) {
|
||||
exitWindowFullscreen();
|
||||
}
|
||||
|
||||
if (!inPictureInPicture) {
|
||||
androidPiPSessionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('kvideo-android-pip-change', handleAndroidPiPChange);
|
||||
return () => window.removeEventListener('kvideo-android-pip-change', handleAndroidPiPChange);
|
||||
}, [exitWindowFullscreen]);
|
||||
|
||||
const togglePictureInPicture = useCallback(async () => {
|
||||
if (!videoRef.current || !isPiPSupported) return;
|
||||
const video = videoRef.current as any;
|
||||
const video = videoRef.current as PiPCapableVideoElement;
|
||||
const fullscreenDocument = getFullscreenDocument();
|
||||
try {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
if (fullscreenDocument.pictureInPictureElement && fullscreenDocument.exitPictureInPicture) {
|
||||
await fullscreenDocument.exitPictureInPicture();
|
||||
} else if (video.webkitPresentationMode === 'picture-in-picture') {
|
||||
video.webkitSetPresentationMode('inline');
|
||||
} else if (video.requestPictureInPicture) {
|
||||
video.webkitSetPresentationMode?.('inline');
|
||||
} else if (video.requestPictureInPicture && fullscreenDocument.pictureInPictureEnabled) {
|
||||
await video.requestPictureInPicture();
|
||||
} else if (video.webkitSupportsPresentationMode && video.webkitSupportsPresentationMode('picture-in-picture')) {
|
||||
} else if (await requestAndroidPictureInPicture()) {
|
||||
return;
|
||||
} else if (
|
||||
video.webkitSupportsPresentationMode?.('picture-in-picture') &&
|
||||
video.webkitSetPresentationMode
|
||||
) {
|
||||
video.webkitSetPresentationMode('picture-in-picture');
|
||||
}
|
||||
} catch (error) {
|
||||
if (await requestAndroidPictureInPicture()) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to toggle Picture-in-Picture:', error);
|
||||
}
|
||||
}, [videoRef, isPiPSupported]);
|
||||
}, [isPiPSupported, requestAndroidPictureInPicture, videoRef]);
|
||||
|
||||
const showAirPlayMenu = useCallback(() => {
|
||||
if (!videoRef.current || !isAirPlaySupported) return;
|
||||
const video = videoRef.current as any;
|
||||
const video = videoRef.current as PiPCapableVideoElement;
|
||||
if (video.webkitShowPlaybackTargetPicker) {
|
||||
video.webkitShowPlaybackTargetPicker();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { usePlayerSettings } from './usePlayerSettings';
|
||||
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
|
||||
interface UseHlsPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
@@ -22,6 +23,7 @@ export function useHlsPlayer({
|
||||
}: UseHlsPlayerProps) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const isAdFilterEnabled = adFilterMode !== 'off';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,6 +227,9 @@ export function useHlsPlayer({
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
if (!mediaProxyEnabled) {
|
||||
throw e;
|
||||
}
|
||||
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
|
||||
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||
const res = await fetch(proxiedUrl);
|
||||
@@ -397,6 +402,10 @@ export function useHlsPlayer({
|
||||
const handleError = () => {
|
||||
if (directFailed) return;
|
||||
directFailed = true;
|
||||
if (!mediaProxyEnabled) {
|
||||
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
|
||||
return;
|
||||
}
|
||||
// Try proxied URL as final attempt
|
||||
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
|
||||
video.src = proxiedUrl;
|
||||
@@ -415,5 +424,5 @@ export function useHlsPlayer({
|
||||
}
|
||||
extraBlobs.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]);
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
premiumModeSettingsStore,
|
||||
type ModeSettings,
|
||||
} from '@/lib/store/premium-mode-settings';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
|
||||
interface PlayerSettingsSnapshot {
|
||||
autoNextEpisode: boolean;
|
||||
@@ -30,7 +31,7 @@ interface PlayerSettingsSnapshot {
|
||||
danmakuDisplayArea: number;
|
||||
}
|
||||
|
||||
function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolean): PlayerSettingsSnapshot {
|
||||
const globalSettings = settingsStore.getSettings();
|
||||
const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings;
|
||||
|
||||
@@ -45,7 +46,7 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
adFilterMode: modeSettings.adFilterMode,
|
||||
adKeywords: globalSettings.adKeywords,
|
||||
fullscreenType: modeSettings.fullscreenType,
|
||||
proxyMode: modeSettings.proxyMode,
|
||||
proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
|
||||
danmakuEnabled: modeSettings.danmakuEnabled,
|
||||
danmakuApiUrl: modeSettings.danmakuApiUrl,
|
||||
danmakuOpacity: modeSettings.danmakuOpacity,
|
||||
@@ -54,17 +55,41 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function playerSettingsEqual(a: PlayerSettingsSnapshot, b: PlayerSettingsSnapshot): boolean {
|
||||
return (
|
||||
a.autoNextEpisode === b.autoNextEpisode &&
|
||||
a.autoSkipIntro === b.autoSkipIntro &&
|
||||
a.skipIntroSeconds === b.skipIntroSeconds &&
|
||||
a.autoSkipOutro === b.autoSkipOutro &&
|
||||
a.skipOutroSeconds === b.skipOutroSeconds &&
|
||||
a.showModeIndicator === b.showModeIndicator &&
|
||||
a.adFilter === b.adFilter &&
|
||||
a.adFilterMode === b.adFilterMode &&
|
||||
a.adKeywords === b.adKeywords &&
|
||||
a.fullscreenType === b.fullscreenType &&
|
||||
a.proxyMode === b.proxyMode &&
|
||||
a.danmakuEnabled === b.danmakuEnabled &&
|
||||
a.danmakuApiUrl === b.danmakuApiUrl &&
|
||||
a.danmakuOpacity === b.danmakuOpacity &&
|
||||
a.danmakuFontSize === b.danmakuFontSize &&
|
||||
a.danmakuDisplayArea === b.danmakuDisplayArea
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access and update player settings from the settings store
|
||||
* Provides reactive updates when settings change
|
||||
*/
|
||||
export function usePlayerSettings(isPremium: boolean = false) {
|
||||
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium));
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
|
||||
|
||||
// Subscribe to settings changes
|
||||
// Subscribe to settings changes. Reuse the previous snapshot when
|
||||
// non-player fields change (e.g. episodeReverseOrder) so HLS is not rebuilt.
|
||||
useEffect(() => {
|
||||
const syncSettings = () => {
|
||||
setSettings(getPlayerSettingsSnapshot(isPremium));
|
||||
const next = getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled);
|
||||
setSettings((prev) => (playerSettingsEqual(prev, next) ? prev : next));
|
||||
};
|
||||
|
||||
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
@@ -77,7 +102,7 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
unsubscribeModeStore();
|
||||
unsubscribeGlobalStore?.();
|
||||
};
|
||||
}, [isPremium]);
|
||||
}, [isPremium, mediaProxyEnabled]);
|
||||
|
||||
const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => {
|
||||
if (isPremium) {
|
||||
@@ -145,8 +170,8 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
}, [updateModeSettings]);
|
||||
|
||||
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
|
||||
updateModeSettings({ proxyMode: value });
|
||||
}, [updateModeSettings]);
|
||||
updateModeSettings({ proxyMode: mediaProxyEnabled ? value : 'none' });
|
||||
}, [mediaProxyEnabled, updateModeSettings]);
|
||||
|
||||
const setDanmakuEnabled = useCallback((value: boolean) => {
|
||||
updateModeSettings({ danmakuEnabled: value });
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
--kvideo-web-scale: 1;
|
||||
}
|
||||
|
||||
.kvideo-container.is-web-fullscreen.top-align-stage {
|
||||
align-items: flex-start !important;
|
||||
padding-top: calc(env(safe-area-inset-top, 0px) + clamp(0px, 1.5vh, 16px)) !important;
|
||||
}
|
||||
|
||||
.kvideo-container.is-web-fullscreen .kvideo-web-fullscreen-stage {
|
||||
position: relative !important;
|
||||
width: min(calc(var(--kvideo-stage-viewport-width, var(--kvideo-viewport-width, 100vw)) * var(--kvideo-web-scale)), calc(var(--kvideo-stage-viewport-height, var(--kvideo-viewport-height, 100vh)) * var(--kvideo-web-scale) * 16 / 9)) !important;
|
||||
|
||||
@@ -78,16 +78,26 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.opacity = '0';
|
||||
if (target.dataset.fallback === '1') {
|
||||
target.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
target.dataset.fallback = '1';
|
||||
target.src = '/placeholder-poster.svg';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder-poster.svg"
|
||||
alt={video.vod_name}
|
||||
fill
|
||||
className="object-cover rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fallback Icon - visible when image fails */}
|
||||
{/* Fallback Icon - visible when image fails completely */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center -z-10 gap-2">
|
||||
<Icons.Film size={48} className="text-[var(--text-color-secondary)] opacity-40" />
|
||||
<span className="text-xs text-[var(--text-color-secondary)] opacity-60 px-2 text-center line-clamp-2">{video.vod_name}</span>
|
||||
@@ -97,20 +107,12 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
|
||||
<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">
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[100%] 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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorite Button - Top Right */}
|
||||
@@ -179,6 +181,9 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
...
|
||||
</span>
|
||||
) : null}
|
||||
{displayLatency !== undefined && (
|
||||
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{video.vod_lang && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import type { AppReleaseEntry, AppUpdateResponse } from '@/lib/types/app-update';
|
||||
|
||||
const DEFAULT_SOURCE = {
|
||||
repository: 'KuekHaoYang/KVideo',
|
||||
branch: 'main',
|
||||
manifestUrl: 'https://raw.githubusercontent.com/KuekHaoYang/KVideo/main/app-release.json',
|
||||
changelogUrl: 'https://github.com/KuekHaoYang/KVideo/blob/main/CHANGELOG.md',
|
||||
repositoryUrl: 'https://github.com/KuekHaoYang/KVideo',
|
||||
};
|
||||
|
||||
function formatDateLabel(value?: string) {
|
||||
if (!value) {
|
||||
return '未记录';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function getStatusMeta(data: AppUpdateResponse | null) {
|
||||
switch (data?.status) {
|
||||
case 'update-available':
|
||||
return {
|
||||
label: '发现新版本',
|
||||
tone: 'text-amber-500 border-amber-500/30 bg-amber-500/10',
|
||||
description: `GitHub 最新版本为 ${data.latestVersion},当前实例仍是 ${data.currentVersion}。`,
|
||||
};
|
||||
case 'ahead-of-remote':
|
||||
return {
|
||||
label: '本地版本较新',
|
||||
tone: 'text-sky-500 border-sky-500/30 bg-sky-500/10',
|
||||
description: `当前实例版本 ${data.currentVersion} 新于 GitHub 检查结果 ${data.latestVersion}。`,
|
||||
};
|
||||
case 'check-failed':
|
||||
return {
|
||||
label: '检查失败',
|
||||
tone: 'text-red-500 border-red-500/30 bg-red-500/10',
|
||||
description: data.error || '远程版本检查失败,当前仅显示本地版本信息。',
|
||||
};
|
||||
case 'up-to-date':
|
||||
default:
|
||||
return {
|
||||
label: '已是最新版本',
|
||||
tone: 'text-emerald-500 border-emerald-500/30 bg-emerald-500/10',
|
||||
description: data
|
||||
? `当前实例版本 ${data.currentVersion} 与 GitHub 最新版本一致。`
|
||||
: '正在获取最新版本信息。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ReleaseNotesBlock({
|
||||
title,
|
||||
release,
|
||||
emptyText,
|
||||
}: {
|
||||
title: string;
|
||||
release: AppReleaseEntry | null;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_55%,transparent)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-color)]">{title}</h3>
|
||||
{release ? (
|
||||
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
{release.version} · {release.title} · {release.publishedAt}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{release ? (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{release.notes.map((note) => (
|
||||
<li key={note} className="flex items-start gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-color)]" />
|
||||
<span>{note}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppVersionSettings() {
|
||||
const [data, setData] = useState<AppUpdateResponse | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchUpdateInfo = useEffectEvent(async (manual: boolean = false) => {
|
||||
if (manual) {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/app-update', {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AppUpdateResponse;
|
||||
setData(payload);
|
||||
setHasLoaded(true);
|
||||
} catch (error) {
|
||||
setHasLoaded(true);
|
||||
setData((previous) => ({
|
||||
currentVersion: previous?.currentVersion || '未知',
|
||||
currentRelease: previous?.currentRelease || null,
|
||||
latestVersion: previous?.latestVersion || previous?.currentVersion || '未知',
|
||||
latestRelease: previous?.latestRelease || previous?.currentRelease || null,
|
||||
status: 'check-failed',
|
||||
updateAvailable: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
checkedRemotely: false,
|
||||
usedRemoteManifest: false,
|
||||
source: previous?.source || DEFAULT_SOURCE,
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
}));
|
||||
} finally {
|
||||
if (manual) {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUpdateInfo(false);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void fetchUpdateInfo(true);
|
||||
};
|
||||
|
||||
const statusMeta = getStatusMeta(data);
|
||||
const currentRelease = data?.currentRelease ?? null;
|
||||
const latestRelease = data?.latestRelease ?? null;
|
||||
const shouldShowLatestRelease = Boolean(
|
||||
latestRelease && (!currentRelease || latestRelease.version !== currentRelease.version),
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="版本与更新"
|
||||
description="查看当前版本、最近更新内容,并手动检查 GitHub 上是否已有新版本。"
|
||||
headerAction={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-[var(--glass-border)] px-4 py-2 text-sm font-medium text-[var(--text-color)] transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--accent-color)_8%,transparent)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
检查更新
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--accent-color)_7%,transparent)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--text-color-secondary)]">当前版本</p>
|
||||
<p className="mt-2 text-3xl font-bold text-[var(--text-color)]">{data?.currentVersion || '加载中...'}</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-color-secondary)]">
|
||||
{currentRelease ? `${currentRelease.title} · ${currentRelease.publishedAt}` : '正在读取本地版本说明'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_55%,transparent)] p-4">
|
||||
<div className={`inline-flex rounded-[var(--radius-full)] border px-3 py-1 text-xs font-semibold ${statusMeta.tone}`}>
|
||||
{statusMeta.label}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--text-color-secondary)]">{statusMeta.description}</p>
|
||||
<p className="mt-2 text-xs text-[var(--text-color-secondary)]">
|
||||
上次检查:{hasLoaded ? formatDateLabel(data?.checkedAt) : '正在检查'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-color-secondary)]">
|
||||
<span>
|
||||
检查来源:{data?.source.repository || 'KuekHaoYang/KVideo'} · {data?.source.branch || 'main'}
|
||||
</span>
|
||||
<Link
|
||||
href={data?.source.changelogUrl || 'https://github.com/KuekHaoYang/KVideo/blob/main/CHANGELOG.md'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[var(--accent-color)] hover:underline"
|
||||
>
|
||||
查看更新日志
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
<Link
|
||||
href={data?.source.repositoryUrl || 'https://github.com/KuekHaoYang/KVideo'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[var(--accent-color)] hover:underline"
|
||||
>
|
||||
查看仓库
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ReleaseNotesBlock
|
||||
title="当前版本更新内容"
|
||||
release={currentRelease}
|
||||
emptyText="当前版本尚未记录更新内容。"
|
||||
/>
|
||||
|
||||
{shouldShowLatestRelease ? (
|
||||
<ReleaseNotesBlock
|
||||
title={`最新可用版本 ${latestRelease?.version}`}
|
||||
release={latestRelease}
|
||||
emptyText="最新版本尚未提供更新说明。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import {
|
||||
type ProxyMode,
|
||||
DEFAULT_SEEK_STEP_SECONDS,
|
||||
@@ -20,6 +22,8 @@ interface PlayerSettingsProps {
|
||||
onProxyModeChange: (mode: ProxyMode) => void;
|
||||
seekStepSeconds: number;
|
||||
onSeekStepSecondsChange: (value: number) => void;
|
||||
videoTogetherEnabled?: boolean;
|
||||
onVideoTogetherEnabledChange?: (enabled: boolean) => void;
|
||||
danmakuApiUrl: string;
|
||||
onDanmakuApiUrlChange: (url: string) => void;
|
||||
danmakuOpacity: number;
|
||||
@@ -47,6 +51,8 @@ export function PlayerSettings({
|
||||
onProxyModeChange,
|
||||
seekStepSeconds,
|
||||
onSeekStepSecondsChange,
|
||||
videoTogetherEnabled,
|
||||
onVideoTogetherEnabledChange,
|
||||
danmakuApiUrl,
|
||||
onDanmakuApiUrlChange,
|
||||
danmakuOpacity,
|
||||
@@ -57,6 +63,9 @@ export function PlayerSettings({
|
||||
onDanmakuDisplayAreaChange,
|
||||
showDanmakuApi = true,
|
||||
}: PlayerSettingsProps) {
|
||||
const { mediaProxyEnabled, restrictionSummary } = useRuntimeFeatures();
|
||||
const effectiveProxyMode = mediaProxyEnabled ? proxyMode : 'none';
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4">播放器设置</h2>
|
||||
@@ -160,42 +169,90 @@ export function PlayerSettings({
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
控制视频播放时的网络请求策略
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => onProxyModeChange('retry')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">智能重试 (推荐)</div>
|
||||
<div className="text-sm opacity-80 mt-1">直连优先,失败时尝试代理</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('none')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">仅直连</div>
|
||||
<div className="text-sm opacity-80 mt-1">不使用代理,失败则报错</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('always')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">总是代理</div>
|
||||
<div className="text-sm opacity-80 mt-1">所有请求都通过代理转发</div>
|
||||
</button>
|
||||
</div>
|
||||
{mediaProxyEnabled ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => onProxyModeChange('retry')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'retry'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">智能重试 (推荐)</div>
|
||||
<div className="text-sm opacity-80 mt-1">直连优先,失败时尝试代理</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('none')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'none'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">仅直连</div>
|
||||
<div className="text-sm opacity-80 mt-1">不使用代理,失败则报错</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onProxyModeChange('always')}
|
||||
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'always'
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">总是代理</div>
|
||||
<div className="text-sm opacity-80 mt-1">所有请求都通过代理转发</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--glass-bg)_70%,transparent)] px-4 py-3">
|
||||
<div className="text-sm font-semibold text-[var(--text-color)]">内置代理端点</div>
|
||||
<code className="mt-1 block break-all rounded-[var(--radius-lg)] bg-black/20 px-3 py-2 text-xs text-[var(--text-color)]">
|
||||
/api/proxy?url=<encoded-video-url>
|
||||
</code>
|
||||
<div className="mt-2 text-sm text-[var(--text-color-secondary)]">
|
||||
此处不是第三方 HTTP/SOCKS 代理配置。播放器会按上方模式把播放地址交给当前 KVideo 部署的内置代理;该能力只在 Docker 或传统 Node.js 自托管完整模式下启用。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-2xl)] border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div className="font-semibold text-[var(--text-color)]">当前部署仅支持直连播放</div>
|
||||
<div className="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
{restrictionSummary}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--text-color-secondary)]">
|
||||
内置代理端点为 /api/proxy,但当前部署模式已禁用该端点。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--glass-border)]" />
|
||||
|
||||
{typeof videoTogetherEnabled === 'boolean' && onVideoTogetherEnabledChange ? (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
|
||||
<Icons.Users size={18} className="text-[var(--accent-color)]" />
|
||||
一起看 (VideoTogether)
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">
|
||||
关闭后不显示一起看悬浮入口;开启后仅在播放器和 IPTV 页面显示,且默认折叠为小图标,用户可自行展开。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={videoTogetherEnabled}
|
||||
onChange={onVideoTogetherEnabledChange}
|
||||
ariaLabel="一起看开关"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--glass-border)]" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Danmaku Settings */}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
|
||||
|
||||
@@ -69,6 +69,27 @@ export const MediaIcons = {
|
||||
</svg>
|
||||
),
|
||||
|
||||
// Web / windowed fullscreen: framed rectangle with expand arrows
|
||||
WebFullscreen: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M14 10l7-7" />
|
||||
<path d="M9 21H3v-6" />
|
||||
<path d="M10 14l-7 7" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
WebFullscreenExit: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v6H3" />
|
||||
<path d="M10 10L3 3" />
|
||||
<path d="M15 21v-6h6" />
|
||||
<path d="M14 14l7 7" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
SkipForward: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polygon points="5 4 15 12 5 20 5 4" />
|
||||
|
||||
@@ -5,12 +5,20 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
files: ["app/api/**/*.ts", "lib/server/**/*.ts"],
|
||||
rules: {
|
||||
"react/display-name": "off",
|
||||
"react/prop-types": "off",
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
".vercel/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
+10
-5
@@ -24,12 +24,12 @@ export async function fetchWithTimeout(
|
||||
|
||||
// If an external signal is provided, propagate its abort
|
||||
const externalSignal = options.signal;
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
} else {
|
||||
const onAbort = () => controller.abort();
|
||||
onAbort = () => controller.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,12 @@ export async function fetchWithTimeout(
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
if (externalSignal && onAbort) {
|
||||
externalSignal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +63,10 @@ export async function withRetry<T>(
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (lastError?.name === 'AbortError') {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (i < retries) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface SourceLatencyResult {
|
||||
latency: number;
|
||||
success: boolean;
|
||||
timeout: boolean;
|
||||
method: 'HEAD' | 'GET';
|
||||
}
|
||||
|
||||
type ProbeAttemptResult = SourceLatencyResult;
|
||||
|
||||
interface ProbeSourceLatencyOptions {
|
||||
fetcher?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
async function probeAttempt(
|
||||
url: string,
|
||||
method: 'HEAD' | 'GET',
|
||||
fetcher: typeof fetch,
|
||||
timeoutMs: number,
|
||||
now: () => number,
|
||||
): Promise<ProbeAttemptResult> {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
const startedAt = now();
|
||||
|
||||
try {
|
||||
await fetcher(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
...(method === 'GET' ? { headers: { Range: 'bytes=0-0' } } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
latency: Math.max(0, Math.round(now() - startedAt)),
|
||||
success: true,
|
||||
timeout: false,
|
||||
method,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
latency: Math.max(0, Math.round(now() - startedAt)),
|
||||
success: false,
|
||||
timeout: timedOut || (error instanceof Error && error.name === 'AbortError'),
|
||||
method,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSourceLatency(
|
||||
url: string,
|
||||
options: ProbeSourceLatencyOptions = {},
|
||||
): Promise<SourceLatencyResult> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const timeoutMs = options.timeoutMs ?? 5000;
|
||||
const now = options.now ?? (() => performance.now());
|
||||
|
||||
const headResult = await probeAttempt(url, 'HEAD', fetcher, timeoutMs, now);
|
||||
if (headResult.success) return headResult;
|
||||
|
||||
return probeAttempt(url, 'GET', fetcher, timeoutMs, now);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import appReleaseJson from '@/app-release.json';
|
||||
import packageJson from '@/package.json';
|
||||
import type { AppReleaseEntry, AppReleaseManifest } from '@/lib/types/app-update';
|
||||
|
||||
const rawManifest = appReleaseJson as AppReleaseManifest;
|
||||
|
||||
export const APP_VERSION = packageJson.version;
|
||||
|
||||
export const LOCAL_RELEASE_MANIFEST: AppReleaseManifest = {
|
||||
...rawManifest,
|
||||
currentVersion: APP_VERSION,
|
||||
};
|
||||
|
||||
function normalizeVersionParts(version: string): Array<number | string> {
|
||||
return version
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
.split(/[.-]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const parsed = Number.parseInt(part, 10);
|
||||
return Number.isNaN(parsed) ? part : parsed;
|
||||
});
|
||||
}
|
||||
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const leftParts = normalizeVersionParts(left);
|
||||
const rightParts = normalizeVersionParts(right);
|
||||
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftPart = leftParts[index] ?? 0;
|
||||
const rightPart = rightParts[index] ?? 0;
|
||||
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
if (leftPart !== rightPart) {
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const comparison = String(leftPart).localeCompare(String(rightPart), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
if (comparison !== 0) {
|
||||
return comparison > 0 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getReleaseByVersion(
|
||||
version: string,
|
||||
manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST,
|
||||
): AppReleaseEntry | null {
|
||||
return manifest.releases.find((release) => release.version === version) ?? null;
|
||||
}
|
||||
|
||||
export function getLatestKnownRelease(
|
||||
manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST,
|
||||
): AppReleaseEntry | null {
|
||||
return manifest.releases[0] ?? null;
|
||||
}
|
||||
|
||||
export function getDefaultRepositorySlug(
|
||||
manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST,
|
||||
): string {
|
||||
return `${manifest.repository.owner}/${manifest.repository.name}`;
|
||||
}
|
||||
|
||||
export function getDefaultRepositoryBranch(
|
||||
manifest: AppReleaseManifest = LOCAL_RELEASE_MANIFEST,
|
||||
): string {
|
||||
return manifest.repository.branch || 'main';
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AuthSession } from '@/lib/store/auth-store';
|
||||
|
||||
export type PasswordGateStateResolution =
|
||||
| {
|
||||
action: 'unlock-session';
|
||||
session: AuthSession;
|
||||
persistSession: boolean;
|
||||
}
|
||||
| {
|
||||
action: 'unlock-public';
|
||||
}
|
||||
| {
|
||||
action: 'lock';
|
||||
clearMirroredSession: boolean;
|
||||
};
|
||||
|
||||
export function resolvePasswordGateState({
|
||||
hasAuth,
|
||||
serverSession,
|
||||
mirroredSession,
|
||||
persistSession,
|
||||
}: {
|
||||
hasAuth: boolean;
|
||||
serverSession: AuthSession | null;
|
||||
mirroredSession: AuthSession | null;
|
||||
persistSession: boolean;
|
||||
}): PasswordGateStateResolution {
|
||||
if (serverSession) {
|
||||
return {
|
||||
action: 'unlock-session',
|
||||
session: serverSession,
|
||||
persistSession,
|
||||
};
|
||||
}
|
||||
|
||||
if (mirroredSession) {
|
||||
return {
|
||||
action: 'lock',
|
||||
clearMirroredSession: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasAuth) {
|
||||
return { action: 'unlock-public' };
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'lock',
|
||||
clearMirroredSession: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export type Role = 'super_admin' | 'admin' | 'viewer';
|
||||
|
||||
export type Permission =
|
||||
| 'source_management'
|
||||
| 'account_management'
|
||||
| 'danmaku_api'
|
||||
| 'data_management'
|
||||
| 'player_settings'
|
||||
| 'danmaku_appearance'
|
||||
| 'view_settings'
|
||||
| 'iptv_access'
|
||||
| 'iptv_source_management'
|
||||
| 'iptv_builtin_sources';
|
||||
|
||||
export const ALL_PERMISSIONS: Permission[] = [
|
||||
'source_management',
|
||||
'account_management',
|
||||
'danmaku_api',
|
||||
'data_management',
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
];
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: [
|
||||
'source_management',
|
||||
'account_management',
|
||||
'danmaku_api',
|
||||
'data_management',
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
],
|
||||
admin: [
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
|
||||
const ROLE_HIERARCHY: Role[] = ['viewer', 'admin', 'super_admin'];
|
||||
|
||||
export function isRole(value: string | undefined | null): value is Role {
|
||||
return value === 'viewer' || value === 'admin' || value === 'super_admin';
|
||||
}
|
||||
|
||||
export function normalizeRole(value: string | undefined | null): Role {
|
||||
return isRole(value) ? value : 'viewer';
|
||||
}
|
||||
|
||||
export function isPermission(value: string | undefined | null): value is Permission {
|
||||
return !!value && ALL_PERMISSIONS.includes(value as Permission);
|
||||
}
|
||||
|
||||
export function normalizePermissions(values: readonly string[] | undefined | null): Permission[] {
|
||||
if (!values || values.length === 0) return [];
|
||||
return values.filter((value): value is Permission => isPermission(value));
|
||||
}
|
||||
|
||||
export function resolvePermissions(role: Role, customPermissions?: readonly string[] | null): Permission[] {
|
||||
const permissions = new Set<Permission>([
|
||||
...(ROLE_PERMISSIONS[role] || []),
|
||||
...normalizePermissions(customPermissions),
|
||||
]);
|
||||
|
||||
if (permissions.has('iptv_access')) {
|
||||
permissions.add('iptv_source_management');
|
||||
}
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
export function hasResolvedPermission(
|
||||
role: Role,
|
||||
permission: Permission,
|
||||
customPermissions?: readonly string[] | null
|
||||
): boolean {
|
||||
return resolvePermissions(role, customPermissions).includes(permission);
|
||||
}
|
||||
|
||||
export function hasRoleAtLeast(role: Role, minimumRole: Role): boolean {
|
||||
return ROLE_HIERARCHY.indexOf(role) >= ROLE_HIERARCHY.indexOf(minimumRole);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
|
||||
export function isVercelDeployment(env: EnvLike = process.env): boolean {
|
||||
return env.VERCEL === '1' || Boolean(env.VERCEL_ENV);
|
||||
}
|
||||
|
||||
export function isCloudflareDeployment(env: EnvLike = process.env): boolean {
|
||||
return (
|
||||
env.CF_PAGES === '1' ||
|
||||
Boolean(env.CF_PAGES_URL) ||
|
||||
Boolean(env.CLOUDFLARE_ACCOUNT_ID) ||
|
||||
Boolean(env.CF_ACCOUNT_ID) ||
|
||||
Boolean(env.WORKERS_CI)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldEnableVercelAnalytics(env: EnvLike = process.env): boolean {
|
||||
return isVercelDeployment(env) && !isCloudflareDeployment(env);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
|
||||
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'y', 'on', 'enabled', 'enable']);
|
||||
|
||||
export const LAN_ACCESS_ENV_KEYS = ['ALLOW_LAN_ACCESS', 'NEXT_PUBLIC_ALLOW_LAN_ACCESS'] as const;
|
||||
export const LAN_ALLOWED_DEV_ORIGINS_ENV_KEYS = [
|
||||
'LAN_ALLOWED_DEV_ORIGINS',
|
||||
'NEXT_PUBLIC_LAN_ALLOWED_DEV_ORIGINS',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_LAN_ALLOWED_DEV_ORIGINS = [
|
||||
'10.*.*.*',
|
||||
...Array.from({ length: 16 }, (_, index) => `172.${16 + index}.*.*`),
|
||||
'192.168.*.*',
|
||||
] as const;
|
||||
|
||||
export function parseBooleanEnv(value: string | undefined | null): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isLanAccessEnabled(env: EnvLike = process.env): boolean {
|
||||
return LAN_ACCESS_ENV_KEYS.some((key) => parseBooleanEnv(env[key]));
|
||||
}
|
||||
|
||||
function normalizeAllowedOriginHost(rawOrigin: string): string | null {
|
||||
const trimmed = rawOrigin.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = new URL(trimmed).hostname;
|
||||
if (hostname) {
|
||||
return hostname.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to host[:port] parsing.
|
||||
}
|
||||
|
||||
const withoutPort = trimmed.match(/^([^:/\s]+):\d+$/)?.[1] ?? trimmed;
|
||||
return withoutPort.toLowerCase();
|
||||
}
|
||||
|
||||
export function parseLanAllowedDevOrigins(value: string | undefined | null): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const origins = value
|
||||
.split(/[\s,]+/)
|
||||
.map(normalizeAllowedOriginHost)
|
||||
.filter((origin): origin is string => Boolean(origin));
|
||||
|
||||
return [...new Set(origins)];
|
||||
}
|
||||
|
||||
export function getLanAllowedDevOrigins(env: EnvLike = process.env): string[] {
|
||||
if (!isLanAccessEnabled(env)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const customOrigins = LAN_ALLOWED_DEV_ORIGINS_ENV_KEYS.flatMap((key) =>
|
||||
parseLanAllowedDevOrigins(env[key])
|
||||
);
|
||||
|
||||
return [...new Set([...DEFAULT_LAN_ALLOWED_DEV_ORIGINS, ...customOrigins])];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type DeploymentProvider = 'self-hosted' | 'vercel' | 'cloudflare';
|
||||
|
||||
export interface RuntimeFeatures {
|
||||
deploymentProvider: DeploymentProvider;
|
||||
deploymentProviderLabel: string;
|
||||
restrictedManagedDeployment: boolean;
|
||||
mediaProxyEnabled: boolean;
|
||||
iptvEnabled: boolean;
|
||||
restrictionSummary: string | null;
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ export const siteConfig: SiteConfig = {
|
||||
title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台",
|
||||
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台",
|
||||
name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store';
|
||||
import { useFavoritesStore, usePremiumFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { keepRenderableFavorites, keepRenderableHistory } from '@/lib/utils/sync-records';
|
||||
import { getProfileId } from '@/lib/store/auth-store';
|
||||
|
||||
export function useCloudSync(isPremium = false) {
|
||||
@@ -15,17 +16,18 @@ export function useCloudSync(isPremium = false) {
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/sync', {
|
||||
headers: { 'x-profile-id': profileId }
|
||||
});
|
||||
const response = await fetch('/api/user/sync');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
if (result.data.history?.length > 0) {
|
||||
historyStore.getState().importHistory(result.data.history);
|
||||
const history = keepRenderableHistory(result.data.history);
|
||||
const favorites = keepRenderableFavorites(result.data.favorites);
|
||||
|
||||
if (history.length > 0) {
|
||||
historyStore.getState().importHistory(history);
|
||||
}
|
||||
if (result.data.favorites?.length > 0) {
|
||||
favoritesStore.getState().importFavorites(result.data.favorites);
|
||||
if (favorites.length > 0) {
|
||||
favoritesStore.getState().importFavorites(favorites);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -46,8 +48,7 @@ export function useCloudSync(isPremium = false) {
|
||||
|
||||
await fetch('/api/user/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-profile-id': profileId,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
+10
-15
@@ -13,13 +13,8 @@ export function useConfigSync() {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasPulled = useRef(false);
|
||||
|
||||
const getHeaders = useCallback(() => {
|
||||
const profileId = getProfileId();
|
||||
if (!profileId) return null;
|
||||
return {
|
||||
'x-profile-id': profileId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const hasSession = useCallback(() => {
|
||||
return !!getProfileId();
|
||||
}, []);
|
||||
|
||||
// Pull config from server on mount (once)
|
||||
@@ -28,11 +23,10 @@ export function useConfigSync() {
|
||||
hasPulled.current = true;
|
||||
|
||||
const pull = async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
if (!hasSession()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/config', { headers });
|
||||
const res = await fetch('/api/user/config');
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -83,7 +77,7 @@ export function useConfigSync() {
|
||||
};
|
||||
|
||||
pull();
|
||||
}, [getHeaders]);
|
||||
}, [hasSession]);
|
||||
|
||||
// Push config to server on settings change (debounced)
|
||||
useEffect(() => {
|
||||
@@ -91,14 +85,15 @@ export function useConfigSync() {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
if (!hasSession()) return;
|
||||
|
||||
try {
|
||||
const settings = settingsStore.getSettings();
|
||||
await fetch('/api/user/config', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sources: settings.sources,
|
||||
premiumSources: settings.premiumSources,
|
||||
@@ -130,5 +125,5 @@ export function useConfigSync() {
|
||||
unsubscribe();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [getHeaders]);
|
||||
}, [hasSession]);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { profiledKey } from '@/lib/utils/profile-storage';
|
||||
import {
|
||||
clampFloatingButtonPosition,
|
||||
getDefaultFloatingButtonPosition,
|
||||
getFloatingButtonRatios,
|
||||
getPositionFromFloatingButtonRatios,
|
||||
type FloatingAnchor,
|
||||
type FloatingButtonPosition,
|
||||
type FloatingButtonRatios,
|
||||
type FloatingButtonViewport,
|
||||
} from '@/lib/utils/floating-button-position';
|
||||
|
||||
type FloatingAnchor = 'left' | 'right';
|
||||
|
||||
interface FloatingButtonPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
interface StoredFloatingPosition extends FloatingButtonRatios {
|
||||
/** Version 2 ratios are measured within the draggable area, not the viewport. */
|
||||
version?: 2;
|
||||
}
|
||||
|
||||
interface StoredFloatingPosition {
|
||||
xRatio: number;
|
||||
yRatio: number;
|
||||
}
|
||||
const CURRENT_STORAGE_VERSION = 2;
|
||||
|
||||
interface UseFloatingButtonPositionOptions {
|
||||
storageKey: string;
|
||||
@@ -45,10 +50,6 @@ const INITIAL_DRAG_STATE: DragState = {
|
||||
offsetY: 0,
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function useFloatingButtonPosition({
|
||||
storageKey,
|
||||
defaultAnchor,
|
||||
@@ -59,32 +60,44 @@ export function useFloatingButtonPosition({
|
||||
const [position, setPosition] = useState<FloatingButtonPosition | null>(null);
|
||||
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
|
||||
const positionRef = useRef<FloatingButtonPosition | null>(null);
|
||||
const customRatiosRef = useRef<FloatingButtonRatios | null>(null);
|
||||
const pointerUpHandlerRef = useRef<(event: PointerEvent) => void>(() => undefined);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({
|
||||
x: clamp(x, margin, Math.max(margin, width - buttonSize - margin)),
|
||||
y: clamp(y, margin, Math.max(margin, height - buttonSize - margin)),
|
||||
...clampFloatingButtonPosition(
|
||||
{ x, y },
|
||||
{ width, height },
|
||||
buttonSize,
|
||||
margin,
|
||||
),
|
||||
}), [buttonSize, margin]);
|
||||
|
||||
const getDefaultPosition = useCallback((width: number, height: number) => {
|
||||
const x = defaultAnchor === 'left'
|
||||
? margin
|
||||
: Math.max(margin, width - buttonSize - margin);
|
||||
const centeredY = height * defaultYRatio - buttonSize / 2;
|
||||
|
||||
return clampPosition(x, centeredY, width, height);
|
||||
}, [buttonSize, clampPosition, defaultAnchor, defaultYRatio, margin]);
|
||||
return getDefaultFloatingButtonPosition(
|
||||
{ width, height },
|
||||
defaultAnchor,
|
||||
defaultYRatio,
|
||||
buttonSize,
|
||||
margin,
|
||||
);
|
||||
}, [buttonSize, defaultAnchor, defaultYRatio, margin]);
|
||||
|
||||
const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const viewport: FloatingButtonViewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
const ratios = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin);
|
||||
const payload: StoredFloatingPosition = {
|
||||
xRatio: nextPosition.x / window.innerWidth,
|
||||
yRatio: nextPosition.y / window.innerHeight,
|
||||
version: CURRENT_STORAGE_VERSION,
|
||||
...ratios,
|
||||
};
|
||||
|
||||
localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload));
|
||||
}, [storageKey]);
|
||||
}, [buttonSize, margin, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -93,6 +106,7 @@ export function useFloatingButtonPosition({
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const fallbackPosition = getDefaultPosition(width, height);
|
||||
customRatiosRef.current = null;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(profiledKey(storageKey));
|
||||
@@ -102,20 +116,30 @@ export function useFloatingButtonPosition({
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<StoredFloatingPosition>;
|
||||
if (typeof parsed.xRatio !== 'number' || typeof parsed.yRatio !== 'number') {
|
||||
const parsed = JSON.parse(raw) as Partial<StoredFloatingPosition> & { version?: number };
|
||||
if (
|
||||
typeof parsed.xRatio !== 'number' ||
|
||||
!Number.isFinite(parsed.xRatio) ||
|
||||
typeof parsed.yRatio !== 'number' ||
|
||||
!Number.isFinite(parsed.yRatio) ||
|
||||
(parsed.version !== undefined && parsed.version !== CURRENT_STORAGE_VERSION)
|
||||
) {
|
||||
positionRef.current = fallbackPosition;
|
||||
setPosition(fallbackPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition = clampPosition(
|
||||
parsed.xRatio * width,
|
||||
parsed.yRatio * height,
|
||||
width,
|
||||
height
|
||||
);
|
||||
const viewport = { width, height };
|
||||
const nextPosition = parsed.version === CURRENT_STORAGE_VERSION
|
||||
? getPositionFromFloatingButtonRatios(
|
||||
{ xRatio: parsed.xRatio, yRatio: parsed.yRatio },
|
||||
viewport,
|
||||
buttonSize,
|
||||
margin,
|
||||
)
|
||||
: clampPosition(parsed.xRatio * width, parsed.yRatio * height, width, height);
|
||||
|
||||
customRatiosRef.current = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin);
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
} catch {
|
||||
@@ -129,9 +153,14 @@ export function useFloatingButtonPosition({
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const fallbackPosition = getDefaultPosition(width, height);
|
||||
const basePosition = positionRef.current || fallbackPosition;
|
||||
const nextPosition = clampPosition(basePosition.x, basePosition.y, width, height);
|
||||
const nextPosition = customRatiosRef.current
|
||||
? getPositionFromFloatingButtonRatios(
|
||||
customRatiosRef.current,
|
||||
{ width, height },
|
||||
buttonSize,
|
||||
margin,
|
||||
)
|
||||
: getDefaultPosition(width, height);
|
||||
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
@@ -139,7 +168,7 @@ export function useFloatingButtonPosition({
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [clampPosition, getDefaultPosition, storageKey]);
|
||||
}, [buttonSize, clampPosition, getDefaultPosition, margin, storageKey]);
|
||||
|
||||
const finishDrag = useCallback(() => {
|
||||
const dragState = dragStateRef.current;
|
||||
@@ -180,9 +209,15 @@ export function useFloatingButtonPosition({
|
||||
window.innerHeight
|
||||
);
|
||||
|
||||
customRatiosRef.current = getFloatingButtonRatios(
|
||||
nextPosition,
|
||||
{ width: window.innerWidth, height: window.innerHeight },
|
||||
buttonSize,
|
||||
margin,
|
||||
);
|
||||
positionRef.current = nextPosition;
|
||||
setPosition(nextPosition);
|
||||
}, [clampPosition]);
|
||||
}, [buttonSize, clampPosition, margin]);
|
||||
|
||||
const handlePointerUp = useCallback((event: PointerEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
@@ -193,10 +228,14 @@ export function useFloatingButtonPosition({
|
||||
|
||||
finishDrag();
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
window.removeEventListener('pointerup', pointerUpHandlerRef.current);
|
||||
window.removeEventListener('pointercancel', pointerUpHandlerRef.current);
|
||||
}, [finishDrag, handlePointerMove]);
|
||||
|
||||
useEffect(() => {
|
||||
pointerUpHandlerRef.current = handlePointerUp;
|
||||
}, [handlePointerUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
|
||||
+62
-30
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { probeLatencyTargets } from '@/lib/utils/latency';
|
||||
|
||||
interface LatencyState {
|
||||
[sourceId: string]: number;
|
||||
@@ -23,17 +24,18 @@ export function useLatencyPing({
|
||||
}: UseLatencyPingOptions) {
|
||||
const [latencies, setLatencies] = useState<LatencyState>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const roundInFlightRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Check if real-time latency is enabled in settings
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false);
|
||||
|
||||
// Stabilize sourceUrls to prevent unnecessary effect re-runs if parent passes new array
|
||||
const stableSourceUrls = useMemo(() => sourceUrls, [
|
||||
// Create a unique key for the sources array
|
||||
sourceUrls.map(s => `${s.id}|${s.baseUrl}`).join(',')
|
||||
]);
|
||||
const stableSourceUrls = useMemo(
|
||||
() => sourceUrls.map((source) => ({ ...source })),
|
||||
[sourceUrls],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
@@ -50,47 +52,64 @@ export function useLatencyPing({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pingSource = useCallback(async (sourceId: string, baseUrl: string): Promise<number | null> => {
|
||||
const pingSource = useCallback(async (baseUrl: string): Promise<number | null> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: baseUrl }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.latency || null;
|
||||
return data.success && typeof data.latency === 'number'
|
||||
? data.latency
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pingAllSources = useCallback(async () => {
|
||||
if (!mountedRef.current || stableSourceUrls.length === 0) return;
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
stableSourceUrls.length === 0 ||
|
||||
roundInFlightRef.current
|
||||
) return;
|
||||
|
||||
roundInFlightRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await Promise.all(
|
||||
stableSourceUrls.map(async ({ id, baseUrl }) => {
|
||||
const latency = await pingSource(id, baseUrl);
|
||||
return { id, latency };
|
||||
})
|
||||
);
|
||||
try {
|
||||
const results = await probeLatencyTargets(
|
||||
stableSourceUrls,
|
||||
({ baseUrl }) => pingSource(baseUrl),
|
||||
);
|
||||
|
||||
if (mountedRef.current) {
|
||||
setLatencies(prev => {
|
||||
const newState = { ...prev };
|
||||
results.forEach(({ id, latency }) => {
|
||||
if (latency !== null) {
|
||||
newState[id] = latency;
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setLatencies(prev => {
|
||||
const newState = { ...prev };
|
||||
results.forEach(({ id, latency }) => {
|
||||
if (latency !== null) {
|
||||
newState[id] = latency;
|
||||
}
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
} finally {
|
||||
roundInFlightRef.current = false;
|
||||
if (mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [stableSourceUrls, pingSource]);
|
||||
|
||||
@@ -101,17 +120,30 @@ export function useLatencyPing({
|
||||
const shouldPoll = enabled && realtimeEnabled && stableSourceUrls.length > 0;
|
||||
|
||||
if (shouldPoll) {
|
||||
// Initial ping
|
||||
pingAllSources();
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
await pingAllSources();
|
||||
if (!cancelled && mountedRef.current) {
|
||||
intervalRef.current = setTimeout(poll, intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up interval
|
||||
intervalRef.current = setInterval(pingAllSources, intervalMs);
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
if (intervalRef.current) {
|
||||
clearTimeout(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
clearTimeout(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
@@ -120,7 +152,7 @@ export function useLatencyPing({
|
||||
const refreshLatency = useCallback((sourceId: string) => {
|
||||
const source = stableSourceUrls.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
pingSource(sourceId, source.baseUrl).then(latency => {
|
||||
pingSource(source.baseUrl).then(latency => {
|
||||
if (latency !== null && mountedRef.current) {
|
||||
setLatencies(prev => ({ ...prev, [sourceId]: latency }));
|
||||
}
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import {
|
||||
getCachedResolution,
|
||||
setCachedResolution,
|
||||
shouldReuseCachedResolution,
|
||||
type ResolutionCacheEntry,
|
||||
} from '@/lib/player/resolution-cache';
|
||||
|
||||
export interface ResolutionInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
function getCached(source: string, id: string | number): ResolutionInfo | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${CACHE_PREFIX}${source}:${id}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(source: string, id: string | number, info: ResolutionInfo) {
|
||||
try {
|
||||
sessionStorage.setItem(`${CACHE_PREFIX}${source}:${id}`, JSON.stringify(info));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
export type ResolutionInfo = ResolutionCacheEntry;
|
||||
|
||||
interface VideoToProbe {
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
interface ResolutionProbeEvent {
|
||||
done?: boolean;
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
resolution?: ResolutionInfo | null;
|
||||
resolutionOrigin?: 'manifest' | 'hint';
|
||||
}
|
||||
|
||||
function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
|
||||
if (typeof window === 'undefined' || videos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const configuredSources = new Map<string, VideoSource>();
|
||||
const { sources, premiumSources } = settingsStore.getSettings();
|
||||
|
||||
[...sources, ...premiumSources].forEach((source) => {
|
||||
if (source?.id) {
|
||||
configuredSources.set(source.id, source);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(new Set(videos.map((video) => video.source)))
|
||||
.map((sourceId) => configuredSources.get(sourceId))
|
||||
.filter((source): source is VideoSource => !!source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that probes actual video resolutions via m3u8 manifests.
|
||||
* Returns a map of "source:id" -> ResolutionInfo.
|
||||
* Results are cached in sessionStorage.
|
||||
*/
|
||||
export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
resolutions: Record<string, ResolutionInfo | null>;
|
||||
@@ -44,35 +57,41 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Track which videos we've already started probing to avoid duplicates
|
||||
const probedKeysRef = useRef<Set<string>>(new Set());
|
||||
const completedKeysRef = useRef<Set<string>>(new Set());
|
||||
const inFlightKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!videos || videos.length === 0) return;
|
||||
|
||||
// Check cache first, find which ones need probing
|
||||
const inFlightKeys = inFlightKeysRef.current;
|
||||
const completedKeys = completedKeysRef.current;
|
||||
const cached: Record<string, ResolutionInfo | null> = {};
|
||||
const needProbe: VideoToProbe[] = [];
|
||||
const batchRequestKeys: string[] = [];
|
||||
|
||||
for (const v of videos) {
|
||||
const key = `${v.source}:${v.id}`;
|
||||
const cachedInfo = getCached(v.source, v.id);
|
||||
if (cachedInfo) {
|
||||
cached[key] = cachedInfo;
|
||||
} else if (!probedKeysRef.current.has(key)) {
|
||||
needProbe.push(v);
|
||||
probedKeysRef.current.add(key);
|
||||
for (const video of videos) {
|
||||
const resultKey = `${video.source}:${video.id}`;
|
||||
const requestKey = `${video.source}:${video.id}:${video.episodeIndex ?? 0}`;
|
||||
const cachedInfo = getCachedResolution(video.source, video.id);
|
||||
|
||||
if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) {
|
||||
cached[resultKey] = cachedInfo;
|
||||
} else if (
|
||||
!completedKeys.has(requestKey) &&
|
||||
!inFlightKeys.has(requestKey)
|
||||
) {
|
||||
needProbe.push(video);
|
||||
batchRequestKeys.push(requestKey);
|
||||
inFlightKeys.add(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cached results immediately
|
||||
if (Object.keys(cached).length > 0) {
|
||||
setResolutions(prev => ({ ...prev, ...cached }));
|
||||
setResolutions((previous) => ({ ...previous, ...cached }));
|
||||
}
|
||||
|
||||
if (needProbe.length === 0) return;
|
||||
|
||||
// Abort previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -81,10 +100,11 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const sourceConfigs = getSourceConfigsForProbe(needProbe);
|
||||
const response = await fetch('/api/probe-resolution', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videos: needProbe }),
|
||||
body: JSON.stringify({ videos: needProbe, sourceConfigs }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -107,29 +127,47 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const data = JSON.parse(line.slice(6)) as ResolutionProbeEvent;
|
||||
if (data.done) continue;
|
||||
const key = `${data.source}:${data.id}`;
|
||||
|
||||
const resultKey = `${data.source}:${data.id}`;
|
||||
const requestKey = `${data.source}:${data.id}:${data.episodeIndex ?? 0}`;
|
||||
inFlightKeys.delete(requestKey);
|
||||
completedKeys.add(requestKey);
|
||||
|
||||
if (data.resolution) {
|
||||
setCache(data.source, data.id, data.resolution);
|
||||
setResolutions(prev => ({ ...prev, [key]: data.resolution }));
|
||||
const resolution: ResolutionInfo = {
|
||||
...data.resolution,
|
||||
origin: data.resolutionOrigin === 'hint' ? 'hint' : 'probed',
|
||||
episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined,
|
||||
};
|
||||
setCachedResolution(data.source, data.id, resolution);
|
||||
setResolutions((previous) => ({ ...previous, [resultKey]: resolution }));
|
||||
} else {
|
||||
setResolutions(prev => ({ ...prev, [key]: null }));
|
||||
setResolutions((previous) => ({ ...previous, [resultKey]: null }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore malformed SSE chunks and continue reading.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
console.warn('[ResolutionProbe] Failed:', e);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
console.warn('[ResolutionProbe] Failed:', error);
|
||||
}
|
||||
} finally {
|
||||
for (const requestKey of batchRequestKeys) {
|
||||
inFlightKeys.delete(requestKey);
|
||||
}
|
||||
setIsProbing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
for (const requestKey of batchRequestKeys) {
|
||||
inFlightKeys.delete(requestKey);
|
||||
}
|
||||
};
|
||||
}, [videos]);
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { SOURCE_IDS } from '@/lib/utils/source-names';
|
||||
import { sortVideos } from '@/lib/utils/sort';
|
||||
import { binaryInsertVideos } from '@/lib/utils/sorted-insert';
|
||||
import { processSearchStream } from '@/lib/utils/search-stream';
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import type { Video } from '@/lib/types';
|
||||
import { useSearchState } from './useSearchState';
|
||||
|
||||
type SearchState = ReturnType<typeof useSearchState>;
|
||||
type SearchSourceConfig = { id: string; baseUrl?: string };
|
||||
|
||||
interface UseSearchActionProps {
|
||||
state: SearchState;
|
||||
@@ -77,7 +76,10 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response stream');
|
||||
|
||||
const sourcesMap = new Map<string, { count: number; name: string }>();
|
||||
const sourceConfigs = new Map<string, SearchSourceConfig>(
|
||||
targetSources.map((source: SearchSourceConfig) => [source.id, source])
|
||||
);
|
||||
const sourcesMap = new Map<string, { count: number; name: string; baseUrl?: string }>();
|
||||
|
||||
await processSearchStream({
|
||||
reader,
|
||||
@@ -95,6 +97,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
|
||||
sourcesMap.set(sourceId, {
|
||||
count: newVideos.length,
|
||||
name: newVideos[0]?.sourceName || sourceId,
|
||||
baseUrl: sourceConfigs.get(sourceId)?.baseUrl,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -113,6 +116,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
|
||||
id: id,
|
||||
name: info.name,
|
||||
count: info.count,
|
||||
...(info.baseUrl ? { baseUrl: info.baseUrl } : {}),
|
||||
}));
|
||||
setAvailableSources(sources);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface PlayerCursorVisibilityState {
|
||||
isFullscreen: boolean;
|
||||
isPlaying: boolean;
|
||||
showControls: boolean;
|
||||
hasInteractiveOverlay?: boolean;
|
||||
}
|
||||
|
||||
export function shouldHidePlayerCursor({
|
||||
isFullscreen,
|
||||
isPlaying,
|
||||
showControls,
|
||||
hasInteractiveOverlay = false,
|
||||
}: PlayerCursorVisibilityState): boolean {
|
||||
return isFullscreen && isPlaying && !showControls && !hasInteractiveOverlay;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export interface DanmakuCanvasSizeCandidates {
|
||||
computedWidth?: number;
|
||||
computedHeight?: number;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
offsetWidth?: number;
|
||||
offsetHeight?: number;
|
||||
boundingWidth?: number;
|
||||
boundingHeight?: number;
|
||||
devicePixelRatio?: number;
|
||||
}
|
||||
|
||||
export interface DanmakuCanvasMetrics {
|
||||
width: number;
|
||||
height: number;
|
||||
dpr: number;
|
||||
bitmapWidth: number;
|
||||
bitmapHeight: number;
|
||||
}
|
||||
|
||||
const METRIC_EPSILON = 0.5;
|
||||
|
||||
function pickPositiveFinite(values: Array<number | undefined>): number | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeDpr(value: number | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
|
||||
export function resolveDanmakuCanvasMetrics(
|
||||
candidates: DanmakuCanvasSizeCandidates,
|
||||
): DanmakuCanvasMetrics | null {
|
||||
const width = pickPositiveFinite([
|
||||
candidates.computedWidth,
|
||||
candidates.clientWidth,
|
||||
candidates.offsetWidth,
|
||||
candidates.boundingWidth,
|
||||
]);
|
||||
const height = pickPositiveFinite([
|
||||
candidates.computedHeight,
|
||||
candidates.clientHeight,
|
||||
candidates.offsetHeight,
|
||||
candidates.boundingHeight,
|
||||
]);
|
||||
|
||||
if (width === null || height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dpr = normalizeDpr(candidates.devicePixelRatio);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
dpr,
|
||||
bitmapWidth: Math.max(1, Math.round(width * dpr)),
|
||||
bitmapHeight: Math.max(1, Math.round(height * dpr)),
|
||||
};
|
||||
}
|
||||
|
||||
export function haveDanmakuCanvasMetricsChanged(
|
||||
previous: DanmakuCanvasMetrics | null,
|
||||
next: DanmakuCanvasMetrics,
|
||||
): boolean {
|
||||
if (!previous) return true;
|
||||
|
||||
return (
|
||||
Math.abs(previous.width - next.width) > METRIC_EPSILON ||
|
||||
Math.abs(previous.height - next.height) > METRIC_EPSILON ||
|
||||
Math.abs(previous.dpr - next.dpr) > 0.01 ||
|
||||
previous.bitmapWidth !== next.bitmapWidth ||
|
||||
previous.bitmapHeight !== next.bitmapHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function scaleDanmakuCoordinate(value: number, previousSize: number, nextSize: number): number {
|
||||
if (!Number.isFinite(value) || previousSize <= 0 || nextSize <= 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value * (nextSize / previousSize);
|
||||
}
|
||||
|
||||
export function clampDanmakuY(value: number, fontSize: number, effectiveHeight: number): number {
|
||||
const minY = Math.max(0, fontSize);
|
||||
const maxY = Math.max(minY, effectiveHeight - fontSize * 0.4);
|
||||
|
||||
return Math.min(maxY, Math.max(minY, value));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface ResolutionCacheEntry {
|
||||
width?: number;
|
||||
height?: number;
|
||||
label: string;
|
||||
color: string;
|
||||
origin?: 'probed' | 'played' | 'hint';
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
export function getResolutionCacheKey(source: string, id: string | number): string {
|
||||
return `${CACHE_PREFIX}${source}:${id}`;
|
||||
}
|
||||
|
||||
export function getCachedResolution(source: string, id: string | number): ResolutionCacheEntry | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(getResolutionCacheKey(source, id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as ResolutionCacheEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedResolution(
|
||||
source: string,
|
||||
id: string | number,
|
||||
info: ResolutionCacheEntry
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(getResolutionCacheKey(source, id), JSON.stringify(info));
|
||||
} catch {
|
||||
// Ignore sessionStorage failures and keep the UI functional.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldReuseCachedResolution(
|
||||
entry: ResolutionCacheEntry | null,
|
||||
episodeIndex?: number
|
||||
): boolean {
|
||||
if (!entry) return false;
|
||||
if (entry.origin === 'played') return true;
|
||||
if (entry.origin === 'hint') return false;
|
||||
return entry.episodeIndex === episodeIndex;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { extractNumericResolutionLabel } from '@/lib/utils/video';
|
||||
|
||||
export interface ResolutionProbeLabel {
|
||||
label: string;
|
||||
color: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const QUALITY_RANK: Record<string, number> = {
|
||||
'8K': 780,
|
||||
'4K': 700,
|
||||
'2K': 620,
|
||||
'1080P': 540,
|
||||
'720P': 420,
|
||||
'540P': 300,
|
||||
'480P': 260,
|
||||
'360P': 220,
|
||||
'240P': 180,
|
||||
'144P': 160,
|
||||
};
|
||||
|
||||
const DIMENSION_PATTERN = /(\d{3,4})\s*[xX]\s*(\d{3,4})/g;
|
||||
const HLS_RESOLUTION_PATTERN = /RESOLUTION=(\d+)x(\d+)/gi;
|
||||
const TEXT_QUALITY_PATTERNS: Array<{ pattern: RegExp; width?: number; height?: number; label: string; color: string }> = [
|
||||
{ pattern: /(?:^|[^\d])(4320p?|8k)(?:[^\d]|$)/i, width: 7680, height: 4320, label: '8K', color: 'bg-rose-500' },
|
||||
{ pattern: /(?:^|[^\d])(2160p?|4k|uhd)(?:[^\d]|$)/i, width: 3840, height: 2160, label: '4K', color: 'bg-amber-500' },
|
||||
{ pattern: /(?:^|[^\d])(1440p?|2k|qhd)(?:[^\d]|$)/i, width: 2560, height: 1440, label: '2K', color: 'bg-emerald-500' },
|
||||
{ pattern: /(?:^|[^\d])(1080p?|1080i|fhd|fullhd|full-hd)(?:[^\d]|$)/i, width: 1920, height: 1080, label: '1080P', color: 'bg-green-500' },
|
||||
{ pattern: /(?:^|[^\d])(720p?|hd720)(?:[^\d]|$)/i, width: 1280, height: 720, label: '720P', color: 'bg-teal-500' },
|
||||
{ pattern: /(?:^|[^\d])540p?(?:[^\d]|$)/i, width: 960, height: 540, label: '540P', color: 'bg-cyan-500' },
|
||||
{ pattern: /(?:^|[^\d])480p?(?:[^\d]|$)/i, width: 854, height: 480, label: '480P', color: 'bg-sky-500' },
|
||||
{ pattern: /(?:^|[^\d])360p?(?:[^\d]|$)/i, width: 640, height: 360, label: '360P', color: 'bg-gray-500' },
|
||||
{ pattern: /(?:^|[^\d])240p?(?:[^\d]|$)/i, width: 426, height: 240, label: '240P', color: 'bg-gray-500' },
|
||||
{ pattern: /(?:^|[^\d])144p?(?:[^\d]|$)/i, width: 256, height: 144, label: '144P', color: 'bg-gray-500' },
|
||||
];
|
||||
|
||||
export function getResolutionLabel(width: number, height: number): ResolutionProbeLabel {
|
||||
const normalizedWidth = Math.max(width, height);
|
||||
const normalizedHeight = Math.min(width, height);
|
||||
|
||||
if (normalizedHeight >= 4320) return { width: normalizedWidth, height: normalizedHeight, label: '8K', color: 'bg-rose-500' };
|
||||
if (normalizedHeight >= 2160) return { width: normalizedWidth, height: normalizedHeight, label: '4K', color: 'bg-amber-500' };
|
||||
if (normalizedHeight >= 1440) return { width: normalizedWidth, height: normalizedHeight, label: '2K', color: 'bg-emerald-500' };
|
||||
if (normalizedHeight >= 1080) return { width: normalizedWidth, height: normalizedHeight, label: '1080P', color: 'bg-green-500' };
|
||||
if (normalizedHeight >= 720) return { width: normalizedWidth, height: normalizedHeight, label: '720P', color: 'bg-teal-500' };
|
||||
if (normalizedHeight >= 540) return { width: normalizedWidth, height: normalizedHeight, label: '540P', color: 'bg-cyan-500' };
|
||||
if (normalizedHeight >= 480) return { width: normalizedWidth, height: normalizedHeight, label: '480P', color: 'bg-sky-500' };
|
||||
if (normalizedHeight >= 360) return { width: normalizedWidth, height: normalizedHeight, label: '360P', color: 'bg-gray-500' };
|
||||
if (normalizedHeight >= 240) return { width: normalizedWidth, height: normalizedHeight, label: '240P', color: 'bg-gray-500' };
|
||||
if (normalizedHeight >= 144) return { width: normalizedWidth, height: normalizedHeight, label: '144P', color: 'bg-gray-500' };
|
||||
return { width: normalizedWidth, height: normalizedHeight, label: `${normalizedHeight}P`, color: 'bg-gray-500' };
|
||||
}
|
||||
|
||||
function getCandidateRank(candidate: ResolutionProbeLabel): number {
|
||||
if (candidate.width && candidate.height) {
|
||||
return candidate.width * candidate.height;
|
||||
}
|
||||
|
||||
return QUALITY_RANK[candidate.label] || 0;
|
||||
}
|
||||
|
||||
export function chooseHigherQuality(
|
||||
current: ResolutionProbeLabel | null,
|
||||
candidate: ResolutionProbeLabel | null
|
||||
): ResolutionProbeLabel | null {
|
||||
if (!candidate) return current;
|
||||
if (!current) return candidate;
|
||||
return getCandidateRank(candidate) > getCandidateRank(current) ? candidate : current;
|
||||
}
|
||||
|
||||
export function extractResolutionHint(...values: Array<string | undefined>): ResolutionProbeLabel | null {
|
||||
let best: ResolutionProbeLabel | null = null;
|
||||
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
DIMENSION_PATTERN.lastIndex = 0;
|
||||
while ((match = DIMENSION_PATTERN.exec(value)) !== null) {
|
||||
const width = Number.parseInt(match[1], 10);
|
||||
const height = Number.parseInt(match[2], 10);
|
||||
if (width > 0 && height > 0) {
|
||||
best = chooseHigherQuality(best, getResolutionLabel(width, height));
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of TEXT_QUALITY_PATTERNS) {
|
||||
if (!pattern.pattern.test(value)) continue;
|
||||
best = chooseHigherQuality(best, {
|
||||
label: pattern.label,
|
||||
color: pattern.color,
|
||||
width: pattern.width,
|
||||
height: pattern.height,
|
||||
});
|
||||
}
|
||||
|
||||
best = chooseHigherQuality(best, extractNumericResolutionLabel(value) || null);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseResolutionFromManifest(content: string, baseUrl?: string): ResolutionProbeLabel | null {
|
||||
let best: ResolutionProbeLabel | null = null;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
HLS_RESOLUTION_PATTERN.lastIndex = 0;
|
||||
while ((match = HLS_RESOLUTION_PATTERN.exec(content)) !== null) {
|
||||
const width = Number.parseInt(match[1], 10);
|
||||
const height = Number.parseInt(match[2], 10);
|
||||
if (width > 0 && height > 0) {
|
||||
best = chooseHigherQuality(best, getResolutionLabel(width, height));
|
||||
}
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim();
|
||||
if (!line) continue;
|
||||
|
||||
if (line.startsWith('#EXT-X-STREAM-INF') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF')) {
|
||||
best = chooseHigherQuality(best, extractResolutionHint(line));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('#')) continue;
|
||||
|
||||
const resolvedLine = baseUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(line, baseUrl).toString();
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
})()
|
||||
: line;
|
||||
|
||||
best = chooseHigherQuality(best, extractResolutionHint(resolvedLine, line));
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function extractVariantPlaylistUrls(content: string, baseUrl: string): string[] {
|
||||
const urls = new Set<string>();
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim();
|
||||
if (!line) continue;
|
||||
|
||||
if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF') && line.includes('URI="')) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/i);
|
||||
if (uriMatch?.[1]) {
|
||||
try {
|
||||
urls.add(new URL(uriMatch[1], baseUrl).toString());
|
||||
} catch {
|
||||
// Ignore invalid URIs.
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.startsWith('#EXT-X-STREAM-INF')) continue;
|
||||
|
||||
const candidate = lines[index + 1]?.trim();
|
||||
if (!candidate || candidate.startsWith('#')) continue;
|
||||
|
||||
try {
|
||||
urls.add(new URL(candidate, baseUrl).toString());
|
||||
} catch {
|
||||
// Ignore invalid URLs.
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(urls);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { extractNumericResolutionLabel } from '@/lib/utils/video';
|
||||
|
||||
export interface ResolutionBadge {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ResolutionLike extends ResolutionBadge {
|
||||
width?: number;
|
||||
height?: number;
|
||||
origin?: 'probed' | 'played' | 'hint';
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
export function shouldExpandForCurrentSource(
|
||||
sources: Array<{ source: string }>,
|
||||
currentSource: string,
|
||||
maxVisible = 5
|
||||
): boolean {
|
||||
const currentIndex = sources.findIndex((source) => source.source === currentSource);
|
||||
return currentIndex >= maxVisible;
|
||||
}
|
||||
|
||||
export function getSourceResolutionBadge(options: {
|
||||
isCurrent: boolean;
|
||||
currentResolution?: ResolutionLike | null;
|
||||
probedResolution?: ResolutionLike | null;
|
||||
cachedResolution?: ResolutionLike | null;
|
||||
remarks?: string;
|
||||
}): ResolutionBadge | null {
|
||||
const { isCurrent, currentResolution, probedResolution, cachedResolution, remarks } = options;
|
||||
|
||||
if (isCurrent && currentResolution) {
|
||||
return { label: currentResolution.label, color: currentResolution.color };
|
||||
}
|
||||
|
||||
if (probedResolution) {
|
||||
return { label: probedResolution.label, color: probedResolution.color };
|
||||
}
|
||||
|
||||
if (cachedResolution) {
|
||||
return { label: cachedResolution.label, color: cachedResolution.color };
|
||||
}
|
||||
|
||||
return extractNumericResolutionLabel(remarks) || null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export function authenticationRequiredResponse(): NextResponse {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
export interface SeedAccountInput {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
}
|
||||
|
||||
export interface StoredAccountRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface SessionCookieProtocolRequest {
|
||||
headers: {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
nextUrl: {
|
||||
protocol: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLoginMode({
|
||||
managedAccountCount,
|
||||
managedAuthEnabled,
|
||||
managedAuthForced,
|
||||
legacyAuthConfigured,
|
||||
}: {
|
||||
managedAccountCount: number;
|
||||
managedAuthEnabled: boolean;
|
||||
managedAuthForced: boolean;
|
||||
legacyAuthConfigured: boolean;
|
||||
}): LoginMode {
|
||||
if (managedAccountCount > 0 || (managedAuthForced && managedAuthEnabled)) {
|
||||
return 'managed';
|
||||
}
|
||||
|
||||
return legacyAuthConfigured ? 'legacy_password' : 'none';
|
||||
}
|
||||
|
||||
export function shouldUseSecureSessionCookie(request?: SessionCookieProtocolRequest): boolean {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const forwardedProtocol = request.headers
|
||||
.get('x-forwarded-proto')
|
||||
?.split(',')[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (forwardedProtocol) {
|
||||
return forwardedProtocol === 'https';
|
||||
}
|
||||
|
||||
return request.nextUrl.protocol === 'https:';
|
||||
}
|
||||
|
||||
// Cloudflare Workers rejects PBKDF2 iteration counts above 100,000.
|
||||
const PBKDF2_ITERATIONS = 100_000;
|
||||
const PBKDF2_KEY_BYTES = 32;
|
||||
const SESSION_TOKEN_VERSION = 'v1';
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
function binaryToBytes(binary: string): Uint8Array {
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function encodeBase64Url(bytes: Uint8Array): string {
|
||||
return btoa(bytesToBinary(bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function decodeBase64Url(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
||||
return binaryToBytes(atob(`${normalized}${padding}`));
|
||||
}
|
||||
|
||||
function encodeText(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function importPbkdf2Key(password: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', toArrayBuffer(encodeText(password)), 'PBKDF2', false, ['deriveBits']);
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(encodeText(secret)),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export function createRandomToken(byteLength = 16): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase64Url(bytes);
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string, salt?: string): Promise<{ hash: string; salt: string }> {
|
||||
const effectiveSalt = salt || createRandomToken();
|
||||
const key = await importPbkdf2Key(password);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
salt: toArrayBuffer(encodeText(effectiveSalt)),
|
||||
},
|
||||
key,
|
||||
PBKDF2_KEY_BYTES * 8
|
||||
);
|
||||
|
||||
return {
|
||||
hash: encodeBase64Url(new Uint8Array(bits)),
|
||||
salt: effectiveSalt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, salt: string, expectedHash: string): Promise<boolean> {
|
||||
const actual = await hashPassword(password, salt);
|
||||
return actual.hash === expectedHash;
|
||||
}
|
||||
|
||||
export function isBootstrapAdminCredential(
|
||||
username: string,
|
||||
password: string,
|
||||
adminPassword: string
|
||||
): boolean {
|
||||
return normalizeUsername(username) === 'admin' && !!adminPassword && password === adminPassword;
|
||||
}
|
||||
|
||||
export async function signSessionPayload(payload: SessionPayload, secret: string): Promise<string> {
|
||||
const payloadBytes = encodeText(JSON.stringify(payload));
|
||||
const encodedPayload = encodeBase64Url(payloadBytes);
|
||||
const message = `${SESSION_TOKEN_VERSION}.${encodedPayload}`;
|
||||
const key = await importHmacKey(secret);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, toArrayBuffer(encodeText(message)));
|
||||
|
||||
return `${message}.${encodeBase64Url(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string, secret: string): Promise<SessionPayload | null> {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [version, encodedPayload, encodedSignature] = parts;
|
||||
if (version !== SESSION_TOKEN_VERSION) return null;
|
||||
|
||||
const key = await importHmacKey(secret);
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
toArrayBuffer(decodeBase64Url(encodedSignature)),
|
||||
toArrayBuffer(encodeText(`${version}.${encodedPayload}`))
|
||||
);
|
||||
|
||||
if (!valid) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeText(decodeBase64Url(encodedPayload)));
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
if (!payload.accountId || !payload.profileId || !payload.name || !payload.role || !payload.mode || !payload.iat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: String(payload.accountId),
|
||||
profileId: String(payload.profileId),
|
||||
username: payload.username ? String(payload.username) : undefined,
|
||||
name: String(payload.name),
|
||||
role: normalizeRole(payload.role),
|
||||
customPermissions: normalizePermissions(payload.customPermissions),
|
||||
mode: payload.mode === 'managed' ? 'managed' : 'legacy',
|
||||
iat: Number(payload.iat),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function ensureUniqueUsername(
|
||||
preferredValue: string,
|
||||
existingUsernames: ReadonlySet<string>,
|
||||
fallbackValue: string
|
||||
): string {
|
||||
const fallbackBase = normalizeUsername(fallbackValue) || 'user';
|
||||
const preferredBase = normalizeUsername(preferredValue) || fallbackBase;
|
||||
|
||||
if (!existingUsernames.has(preferredBase)) {
|
||||
return preferredBase;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (existingUsernames.has(`${preferredBase}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${preferredBase}-${suffix}`;
|
||||
}
|
||||
|
||||
export function parseBootstrapAccounts(rawAccounts: string): SeedAccountInput[] {
|
||||
if (!rawAccounts.trim()) return [];
|
||||
|
||||
const usernames = new Set<string>();
|
||||
const seeds: SeedAccountInput[] = [];
|
||||
|
||||
rawAccounts
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((entry, index) => {
|
||||
const parts = entry.split(':').map((part) => part.trim());
|
||||
if (parts.length < 2) return;
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let name = '';
|
||||
let rolePart = '';
|
||||
let permissionsPart = '';
|
||||
|
||||
if (parts.length === 2) {
|
||||
[password, name] = parts;
|
||||
} else if (parts.length === 3) {
|
||||
if (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin') {
|
||||
[password, name, rolePart] = parts;
|
||||
} else {
|
||||
[username, password, name] = parts;
|
||||
}
|
||||
} else if (parts.length === 4 && (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin')) {
|
||||
[password, name, rolePart, permissionsPart] = parts;
|
||||
} else {
|
||||
[username, password, name, rolePart, permissionsPart] = parts;
|
||||
}
|
||||
|
||||
if (!password || !name) return;
|
||||
|
||||
const normalizedUsername = ensureUniqueUsername(
|
||||
username || name,
|
||||
usernames,
|
||||
`user-${index + 1}`
|
||||
);
|
||||
|
||||
usernames.add(normalizedUsername);
|
||||
seeds.push({
|
||||
username: normalizedUsername,
|
||||
password,
|
||||
name,
|
||||
role: normalizeRole(rolePart),
|
||||
customPermissions: normalizePermissions(permissionsPart ? permissionsPart.split('|') : []),
|
||||
});
|
||||
});
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
export async function createStoredAccount(
|
||||
input: SeedAccountInput,
|
||||
now = Date.now()
|
||||
): Promise<StoredAccountRecord> {
|
||||
const password = await hashPassword(input.password);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
customPermissions: input.customPermissions,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user