Author SHA1 Message Date
kuekhaoyang 3771b63332 feat: add self-contained strict verification chain
Keep every post-59948b5 change inside verification/. The verifier owns its npm working directory and sanitized Docker context, so no release metadata or root Docker configuration is modified.
2026-08-02 06:11:13 +08:00
Kuek Hao YangandGitHub 59948b5085 Merge pull request #231 from KuekHaoYang/agent/release-4.9.19
fix: ship self-hosted sync and image fallback (v4.9.19)
2026-07-31 23:19:17 +08:00
kuekhaoyang f1a0086701 fix: harden sync and image fallback; release 4.9.19 2026-07-31 23:17:50 +08:00
kuekhaoyang 4983a9e2ab Merge remote-tracking branch 'origin/pr-230' into agent/release-4.9.19 2026-07-31 23:08:41 +08:00
kuekhaoyang 3db31b4d80 Merge remote-tracking branch 'origin/pr-229' into agent/release-4.9.19 2026-07-31 23:08:41 +08:00
kuekhaoyang c64458bd6d Merge remote-tracking branch 'origin/pr-227' into agent/release-4.9.19 2026-07-31 23:08:41 +08:00
can4hou6joeng4 3bf72e4b66 fix(douban): 图片代理按镜像回退,修复境外部署海报大量 500
imgN.doubanio.com 互为镜像但解析到不同线路:img1 当前解析到国内 IP
180.97.198.41,在境外主机上不可达。此时 fetch 是直接抛异常而不是返回
状态码,于是走进 catch 一律返回 500,首页会有相当比例的海报加载失败。

改为在 imgN.doubanio.com 之间按 img9 -> img3 -> img2 顺序回退,同一
路径换个镜像即可取到。同时保留最后一次的真实状态码,不再把上游的 403
或 404 也统一伪装成 500。
2026-07-31 18:32:34 +08:00
can4hou6joeng4 b63f94fed4 fix(sync): 丢弃缺少 videoId 的同步记录,避免整页崩溃
服务端同步恢复回来的记录不保证结构完整(旧结构或半截写入)。缺少 videoId
的记录会让 HistoryItem.tsx:24 与 FavoritesItem.tsx:21 在渲染期拼播放地址
时对 undefined 调用 toString(),抛出未捕获异常。由于 getVideoUrl() 是在
渲染 href 时调用的,React 会直接卸载整棵树,首页白屏且刷新无效——该账号
只能靠直接改库才能恢复。

新增 lib/utils/sync-records.ts 做可渲染性判定(要求 videoId、source、
title 齐备),在云端拉取入口 useCloudSync 与 HistoryList / FavoritesList
各过滤一次:脏数据进不来,已经落库的也渲染不出来。
2026-07-31 18:28:35 +08:00
can4hou6joeng4 81c46d754e fix(sync): 修复自托管下无法读取 Upstash 配置
app/api/user/{sync,config} 用 @upstash/redis/cloudflare 的
Redis.fromEnv() 创建客户端。该实现只读传入的 env 参数或 Cloudflare
全局绑定,从不回退到 process.env,因此在 Docker / Node 自托管下
url 与 token 恒为 undefined,两个接口必定返回 500,跨设备同步
(收藏、历史、设置)完全不可用。认证走的是 auth.ts 里的
new Redis({ url, token }),所以登录正常,只有用户数据读写失败。

把 auth.ts 中已有的双源回退抽成 lib/server/runtime-env.ts,Redis
客户端抽成 lib/server/redis.ts,两个路由改为惰性获取共享客户端。
惰性创建同时修正了另一个隐患:Cloudflare 的 per-request 绑定在
模块求值阶段不可达,而原先客户端是在模块作用域创建的。

未配置 Upstash 时返回 503 并给出明确说明,而不是当成请求失败。

Fixes #226
2026-07-31 10:12:40 +08:00
Kuek Hao YangandGitHub 0675c28413 Merge pull request #225 from KuekHaoYang/agent/fix-issue-224-mobile-fullscreen
Fix mobile player controls so the system fullscreen action remains visible on narrow screens.
2026-07-29 22:40:33 +08:00
kuekhaoyang 0294f59602 fix: keep mobile fullscreen controls visible 2026-07-29 22:36:05 +08:00
Kuek Hao YangandGitHub 46f6eee2a6 Merge pull request #222 from Troray/feature/ad-filter-upgrade
Harden M3U8 ad filtering and prevent false positives
2026-07-29 22:15:20 +08:00
kuekhaoyang cdab3b1c8a fix: harden M3U8 ad filtering
Reject unsafe dynamic filter execution, reduce heuristic false positives, add regression coverage, bump the app to 4.9.17, and update Next.js to the latest stable security patch.
2026-07-29 22:13:54 +08:00
Troray c53801d3bf feat(ad-filter): upgrade M3U8 ad filtering algorithm with dynamic script sandbox, zero false-positive framerate protections, and Edge API 2026-07-29 13:38:31 +08:00
Kuek Hao YangandGitHub 6151edab4d Merge pull request #221 from KuekHaoYang/agent/fix-issue-220-floating-button-resize
fix: keep floating buttons anchored on resize
2026-07-27 21:19:51 +08:00
kuekhaoyang 907f393e2f fix: keep floating buttons anchored on resize 2026-07-27 21:13:08 +08:00
kuekhaoyang 1179148031 fix: avoid false-positive WebView transpile guard failures
Keep chrome69 downleveling for Android 9 WebViews, but only hard-fail on
logical assignment after transpile. Regex/polyfill feature detects and
minified ternaries can contain `??`/`?.` character sequences that are not
optional chaining or nullish coalescing operators.
2026-07-25 14:48:28 +08:00
kuekhaoyang e04b306334 Merge branch 'goal/issues-217-218-pr-219'
Ship 4.9.15: #217 Android TV 9 white screen, #218 player UX, PR #219 ad fingerprinting (safely integrated).
2026-07-25 14:43:42 +08:00
kuekhaoyang f98de077f7 fix: player UX, ad fingerprinting, Android TV 9 white screen
Address issues #217 #218 and integrate the useful parts of PR #219 without
regressions: stop player rebuild on episode reverse, grid/paged episodes,
poster placeholders, chrome69 client transpile for Amlogic WebView, and
duration-signature ad block detection while keeping interstitial/proxy filters.

Bump to 4.9.15.
2026-07-25 14:43:30 +08:00
Kuek Hao YangandGitHub c846c60d0c Merge pull request #216 from ustdbus/codex/upstream-cloudflare-auth
fix: support managed Upstash auth on Cloudflare Pages
2026-07-21 20:53:36 +08:00
kuekhaoyang 2ad9203e39 chore: preserve lockfile platform metadata 2026-07-21 20:51:05 +08:00
kuekhaoyang a1a3821538 fix: complete managed admin persistence 2026-07-21 20:50:34 +08:00
Codex 387da736e1 fix: cap PBKDF2 iterations for Cloudflare Workers 2026-07-20 16:13:52 +08:00
Codex dd4b7c9f24 fix: authenticate environment admin before Redis hashing 2026-07-20 16:13:52 +08:00
Codex b7e962b031 fix: read Cloudflare request environment 2026-07-20 16:13:51 +08:00
Codex 2a59e31cd4 fix: read admin password at runtime on Cloudflare 2026-07-20 16:13:51 +08:00
Codex 68d4fd944d fix: sync managed admin password from environment 2026-07-20 16:13:51 +08:00
Codex 27d30139c1 fix: support Upstash Redis on Cloudflare Pages 2026-07-20 16:13:51 +08:00
Kuek Hao YangandGitHub 8d558c79cf Merge pull request #211 from KuekHaoYang/agent/fix-issue-210-search-latency
修复慢源搜索提前结束与延迟虚高
2026-07-13 21:15:17 +08:00
kuekhaoyang 66d8564dd2 fix issue 210 slow source search reliability 2026-07-13 21:09:47 +08:00
kuekhaoyang 2ae0a03366 ci build Docker architectures natively 2026-07-12 01:38:19 +08:00
kuekhaoyang 0f82ec5660 fix Docker version output step 2026-07-12 01:11:28 +08:00
kuekhaoyang e135c6b084 ci publish versioned Docker images 2026-07-12 01:10:17 +08:00
kuekhaoyang 7474979308 merge issue 207 account actions fix 2026-07-12 01:03:03 +08:00
kuekhaoyang 7ae08e2d7d fix issue 207 account actions visibility 2026-07-12 01:02:47 +08:00
kuekhaoyang d6f2a2e3c7 fix login gate session loop 2026-07-09 17:39:46 +08:00
kuekhaoyang db72a74817 Remove unused quick search component 2026-07-09 17:19:00 +08:00
kuekhaoyang 8c522119c0 Remove homepage quick search groups 2026-07-09 17:13:13 +08:00
kuekhaoyang 2bb70ca648 Fix tag management view entering search flow 2026-07-09 16:59:39 +08:00
kuekhaoyang 5a76958816 merge issue 204 auth sync fix 2026-07-09 16:34:16 +08:00
kuekhaoyang 610f57c5b0 fix auth sync requests after login 2026-07-09 16:33:44 +08:00
kuekhaoyang d325603123 fix: support WebView 83 client assets 2026-07-09 16:07:57 +08:00
kuekhaoyang bacd3f887e Fix mobile danmaku canvas scaling 2026-07-09 15:48:38 +08:00
kuekhaoyang 93e6699937 fix: disable Vercel analytics outside Vercel 2026-07-09 15:07:34 +08:00
kuekhaoyang 89d1d7268b chore: ignore Wrangler local state in Docker context 2026-07-08 23:39:47 +08:00
kuekhaoyang 47b691a097 chore: exclude generated TypeScript files from Docker context 2026-07-08 23:38:04 +08:00
kuekhaoyang 8ebc2ae009 chore: ignore Cloudflare build output in Docker context 2026-07-08 23:32:42 +08:00
kuekhaoyang 787680468b feat: clarify proxy and add LAN access toggle 2026-07-08 23:28:25 +08:00
kuekhaoyang 0f39d65b75 fix: default homepage to popular tab
Ensure the homepage always opens on the 热门 tag instead of automatically selecting 为你推荐, even when viewing history exists or cached tags omit the popular tag.

Fixes #182

Fixes #190
2026-07-08 23:00:09 +08:00
kuekhaoyang 62dafbfe59 feat: add homepage category shortcuts 2026-07-08 22:34:29 +08:00
kuekhaoyang dedf496761 fix: hide cursor during fullscreen playback 2026-07-08 22:12:34 +08:00
kuekhaoyang f2667d42de chore: upgrade Next.js 2026-07-08 21:50:07 +08:00
Kuek Hao YangandGitHub 6451ffb8ed Merge pull request #192 from edison0817/dev1
增加强制开启托管账户的环境变量
2026-07-08 21:37:02 +08:00
kuekhaoyang a5f9293658 Fix managed auth force mode 2026-07-08 21:36:06 +08:00
Kuek Hao YangandGitHub cb4593d947 Merge pull request #198 from fyzhu/main
style: video card
2026-07-08 21:15:15 +08:00
Wanfung Chu 5d17c72b3f style: set video card badge width 100% 2026-06-30 11:35:44 +08:00
Wanfung Chu db23dd732b style: video card 2026-06-30 11:17:18 +08:00
edison0817andGitHub 7b57bd1a1f Update README.md
增加强制开启托管账户环境变量说明
2026-06-03 13:38:38 +08:00
edison0817andGitHub 0111318f30 Update auth.ts
add开启托管账户环境变量
2026-06-03 13:33:43 +08:00
Kuek Hao YangandGitHub 20c9ae3d56 Merge pull request #162 from KuekHaoYang/codex/fix-android-pip-issue-159
Fix Android APK PiP capture for inline and system fullscreen
2026-04-16 17:50:28 +08:00
kuekhaoyang c1bcb5141a Fix Android APK PiP capture 2026-04-16 17:06:37 +08:00
Kuek Hao YangandGitHub f93f3ab82f Merge pull request #161 from KuekHaoYang/fix/numeric-resolution-badges
Make source badges numeric only
2026-04-16 16:42:35 +08:00
kuekhaoyang eef52c751c Make source badges numeric only 2026-04-16 16:41:37 +08:00
Kuek Hao YangandGitHub d7ba651950 Merge pull request #160 from KuekHaoYang/fix/source-resolution-badges
Fix source resolution badge probing
2026-04-16 16:32:08 +08:00
kuekhaoyang 49951c67ef Fix source resolution badge probing 2026-04-16 16:31:17 +08:00
Kuek Hao YangandGitHub 0f6060886c Merge pull request #157 from KuekHaoYang/fix/issue-72-omnibus
Fix managed auth and source list regressions
2026-04-15 23:05:28 +08:00
kuekhaoyang 19e9707041 Fix managed auth and source list regressions 2026-04-15 23:04:41 +08:00
Kuek Hao YangandGitHub 4f0f9308bb Merge pull request #156 from KuekHaoYang/fix/android-tv-pip-recursion
Fix Android TV PiP bridge recursion
2026-04-15 21:58:15 +08:00
kuekhaoyang 9d76bfdaf0 Fix Android TV PiP bridge recursion 2026-04-15 21:57:06 +08:00
kuekhaoyang a205393da0 fix(settings): stabilize update check status 2026-04-12 12:34:09 +08:00
kuekhaoyang 833077a1f6 fix(cloudflare): remove node-only site icon route 2026-04-12 12:23:03 +08:00
kuekhaoyang 4fb718f8d8 feat(settings): add version and update status 2026-04-12 11:57:38 +08:00
kuekhaoyang 727fc2b0ed fix(docker): make the site icon configurable at runtime
Add SITE_ICON_FILE and SITE_ICON_URL support for the Docker image, route the app shell and manifest through a runtime icon endpoint, document the new Docker branding flow, and bump the app version to 4.8.3.
2026-04-12 11:26:57 +08:00
kuekhaoyang e2d7549a70 fix(player): expand the default player viewport
Remove the extra player shell, make the inline player full width on phones, align portrait web fullscreen to the top, and bump the app version to 4.8.2.

Closes #154
2026-04-12 10:51:48 +08:00
kuekhaoyang 15f6e434c8 fix(player): guard cast SDK in Android WebView
Fixes #147
2026-04-09 22:41:15 +08:00
kuekhaoyang fb4f50ee7f Hide fullscreen clock with player controls
Fixes #141
2026-04-08 14:05:53 +08:00
kuekhaoyang 372e60c4e5 fix: restore Android PiP/fullscreen and remove stale media caching
Fixes #142
2026-04-08 13:49:24 +08:00
kuekhaoyang 4db08dbf72 fix: sync runtime config into settings 2026-04-08 13:36:09 +08:00
kuekhaoyang e93c259e4a fix: make Android TV setup usable on Android 9
Fixes #144
2026-04-07 22:23:15 +08:00
Kuek Hao YangandGitHub a380ed4133 Merge pull request #145 from LamLarryyyy/feat/favorites-page
feat: add dedicated My Favourites page with grid view
2026-04-07 22:08:08 +08:00
kuekhaoyang f4f5597a80 fix: polish favorites page implementation 2026-04-07 22:05:39 +08:00
LamLarryyyyandClaude Sonnet 4.6 ddcce8341a feat: add dedicated My Favourites page with grid view
Add a full-page favourites view accessible via heart icon in the navbar.
Supports sorting by date/title, clear all, infinite scroll pagination,
and separate pages for normal (/favorites) and premium (/premium/favorites) modes.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-07 14:37:16 +08:00
kuekhaoyang 38fe8e00fb fix cloudflare pages direct upload build 2026-04-02 22:41:49 +08:00
kuekhaoyang e73416d76b gate VideoTogether behind settings 2026-04-02 22:29:27 +08:00
kuekhaoyang 37b6e160d7 fix remaining issue 72 ui and iptv regressions 2026-04-02 22:14:46 +08:00
kuekhaoyang 5829a79ee3 fix: enforce compliance mode for managed deployments 2026-04-02 21:42:00 +08:00
kuekhaoyang 9762e0a6b5 Make Android TV APK build manual only 2026-04-02 21:19:19 +08:00
kuekhaoyang f66ca2fd3e Reduce redundant workflow runs 2026-04-02 21:17:00 +08:00
286 changed files with 23772 additions and 2797 deletions
+4
View File
@@ -4,4 +4,8 @@ node_modules
npm-debug.log npm-debug.log
README.md README.md
.next .next
.vercel
.wrangler
*.tsbuildinfo
next-env.d.ts
.git .git
@@ -15,6 +15,10 @@ on:
- cron: "0 */6 * * *" - cron: "0 */6 * * *"
workflow_dispatch: workflow_dispatch:
concurrency:
group: upstream-sync-${{ github.repository }}
cancel-in-progress: true
jobs: jobs:
sync_latest_from_upstream: sync_latest_from_upstream:
name: Sync latest commits from upstream repo name: Sync latest commits from upstream repo
+5 -9
View File
@@ -1,14 +1,6 @@
name: Android TV APK name: Android TV APK
on: on:
pull_request:
paths:
- "android-tv/**"
- ".github/workflows/android-tv-apk.yml"
push:
paths:
- "android-tv/**"
- ".github/workflows/android-tv-apk.yml"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release_tag: release_tag:
@@ -27,11 +19,15 @@ on:
permissions: permissions:
contents: write contents: write
concurrency:
group: android-tv-apk-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build_android_tv_apk: build_android_tv_apk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
KVIDEO_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.kvideo_url || '' }} KVIDEO_URL: ${{ inputs.kvideo_url || '' }}
defaults: defaults:
run: run:
working-directory: android-tv working-directory: android-tv
+121
View File
@@ -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
View File
@@ -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
View File
@@ -79,7 +79,7 @@ USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
# set hostname to localhost # Bind the standalone server to all container interfaces.
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]
+204 -62
View File
@@ -23,6 +23,8 @@
**KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4,打造了一个既美观又强大的视频浏览平台。 **KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4,打造了一个既美观又强大的视频浏览平台。
> **说明**:仓库默认不内置任何视频源、高级源或 IPTV 源。部署者必须自行配置已获授权、可合法使用且允许当前部署方式访问的内容来源。
### 核心设计理念:Liquid Glass(液态玻璃) ### 核心设计理念:Liquid Glass(液态玻璃)
项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言: 项目的视觉设计基于 **"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` 环境变量设置管理员密码: 通过 `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` 将作为管理员密码使用。 > **向后兼容**`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
### 方式:多账户系统 ### 方式:多账户系统(环境变量模式)
通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。 通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。 **兼容格式:**
- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]`
- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]`
多个账户之间用逗号分隔。
- **角色**`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认) - **角色**`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限 - **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
@@ -320,7 +349,9 @@ docker run -d -p 3000:3000 \
这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。 这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
### 方式三:高级内容独立密码 > 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。
### 方式四:高级内容独立密码
通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。 通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
@@ -340,7 +371,7 @@ docker run -d -p 3000:3000 \
- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入 - 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
- 不设置此变量时,高级内容无额外密码保护 - 不设置此变量时,高级内容无额外密码保护
### 方式:会话持久化设置 ### 方式:会话持久化设置
通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话: 通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话:
@@ -376,15 +407,6 @@ docker run -d -p 3000:3000 \
- 变量名:`NEXT_PUBLIC_SITE_NAME` - 变量名:`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.local` 文件:
```env ```env
@@ -393,6 +415,44 @@ NEXT_PUBLIC_SITE_TITLE=我的视频 - 聚合播放平台
NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台 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) 配置 ## 一起看 (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_SCRIPT_URL` | 自定义 VideoTogether 脚本地址,适合自托管或替换 CDN | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` |
| `VIDEOTOGETHER_SETTING_URL` | 自定义 VideoTogether 设置页地址,对应官方 `window.videoTogetherWebsiteSettingUrl` 接口 | - | | `VIDEOTOGETHER_SETTING_URL` | 自定义 VideoTogether 设置页地址,对应官方 `window.videoTogetherWebsiteSettingUrl` 接口 | - |
@@ -500,6 +560,8 @@ docker run -d -p 3000:3000 \
通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。 通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。
> **注意**:在 Vercel / Cloudflare 托管部署的合规模式下,IPTV 页面与流中继默认关闭,`IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` 不会生效。
| 变量名 | 说明 | 默认值 | | 变量名 | 说明 | 默认值 |
|--------|------|--------| |--------|------|--------|
| `IPTV_SOURCES` | IPTV 直播源配置(服务端) | - | | `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 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 格式
如果你想创建自己的订阅源或批量导入源,可以使用以下 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` | - | | `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD` | - |
| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - | | `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]``用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - |
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - | | `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` | | `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
| `PORT` | 自定义应用端口 | `3000` | | `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_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` | | `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` | | `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
| `SITE_ICON_FILE` | Docker 运行时图标文件路径(优先于 `SITE_ICON_URL` | - |
| `SITE_ICON_URL` | Docker 运行时图标 URL 或站内路径 | - |
| `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - | | `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - |
| `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - | | `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - |
| `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - | | `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` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - |
| `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - | | `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - |
| `DANMAKU_API_URL` / `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - | | `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_SCRIPT_URL` | VideoTogether 脚本地址 | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` |
| `VIDEOTOGETHER_SETTING_URL` | VideoTogether 设置页地址 | - | | `VIDEOTOGETHER_SETTING_URL` | VideoTogether 设置页地址 | - |
| `UPSTASH_REDIS_REST_URL` | Upstash Redis REST URL(跨设备同步:配置、历史、收藏) | - | | `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**:代码质量检查 - **ESLint 10**:代码质量检查
- **PostCSS 8**CSS 处理器 - **PostCSS 8**CSS 处理器
- **Vercel Analytics**:性能监控和分析 - **Vercel Analytics**仅在 Vercel 部署中启用的性能监控和分析
- **Cloudflare Pages**:边缘部署支持 - **Cloudflare Pages**:边缘部署支持
### 架构特点 ### 架构特点
- **App Router**Next.js 的新路由系统,支持服务端组件和流式渲染 - **App Router**Next.js 的新路由系统,支持服务端组件和流式渲染
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、视频源代理、IPTV 代理和弹幕聚合 - **API Routes**:内置 API 端点,处理认证、豆瓣数据、弹幕聚合,以及仅在自托管完整模式下启用的媒体代理 / IPTV 中继
- **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果 - **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果
- **Service Worker**PWA 支持和资源缓存 - **Service Worker**PWA 支持和资源缓存
- **Server Components**:优化首屏加载性能 - **Server Components**:优化首屏加载性能
@@ -674,45 +779,25 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
- **Edge Runtime**API 路由运行在 Edge Runtime 上,提供更低延迟 - **Edge Runtime**API 路由运行在 Edge Runtime 上,提供更低延迟
- **Profile-based Storage**:所有用户数据按 profileIdSHA-256 哈希)隔离存储 - **Profile-based Storage**:所有用户数据按 profileIdSHA-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/)
## 快速部署 ## 快速部署
### 部署到自己的服务器 ### 部署到自己的服务器
#### 选项 1Vercel 一键部署(推荐 #### 选项 1Docker 部署(推荐,完整功能
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/KuekHaoYang/KVideo)
1. 点击上方按钮
2. 连接你的 GitHub 账号
3. Vercel 会自动检测 Next.js 项目并部署
4. 几分钟后即可访问你自己的 KVideo 实例
#### 选项 2Cloudflare 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`,然后重新部署。
#### 选项 3Docker 部署
**从 Docker Hub 拉取(最简单):** **从 Docker Hub 拉取(最简单):**
@@ -758,7 +843,7 @@ docker run -d -p 3000:3000 \
--name kvideo kuekhaoyang/kvideo:latest --name kvideo kuekhaoyang/kvideo:latest
``` ```
#### 选项 4:传统 Node.js 部署 #### 选项 2:传统 Node.js 部署(完整功能)
```bash ```bash
# 1. 克隆仓库 # 1. 克隆仓库
@@ -777,7 +862,30 @@ npm start
应用将在 `http://localhost:3000` 启动。 应用将在 `http://localhost:3000` 启动。
#### 选项 5Android TV APK 构建 同一局域网设备需要直接访问当前设备 IP 时,使用 `ALLOW_LAN_ACCESS=true npm run start`,并确认系统防火墙允许入站访问对应端口。
#### 选项 3Vercel / 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 自托管,不要在托管平台上强行恢复这些能力。
#### 选项 4Android TV APK 构建
项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。 项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。
@@ -829,7 +937,7 @@ npm start
> >
> **发布方式**GitHub Actions 的 `Android TV APK` 工作流会持续验证壳应用可构建;如需对外发布预构建 APK,可手动触发该工作流并填写 `release_tag`,生成对应的 GitHub Release 资产。 > **发布方式**GitHub Actions 的 `Android TV APK` 工作流会持续验证壳应用可构建;如需对外发布预构建 APK,可手动触发该工作流并填写 `release_tag`,生成对应的 GitHub Release 资产。
#### 选项 6Apple TV 应用构建 #### 选项 5Apple TV 应用构建
项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。 项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。
@@ -865,7 +973,18 @@ npm start
### Vercel 部署 ### Vercel 部署
Vercel 会自动检测 GitHub 仓库的更新并重新部署,无需手动操作 不要依赖 GitHub 仓库自动导入。请在本地工作副本完成更新后,使用 Vercel CLI 重新发布当前目录
### Cloudflare 托管部署
请在本地工作副本完成更新后,重新执行:
```bash
npm install
npm run pages:build
```
然后使用 Direct Upload 或 CLI 发布 **`.vercel/output/static`**。不要上传仓库根目录,也不要上传 `.next`,否则 Pages 只会托管源码文件,访问站点时直接 404。
### Docker 部署 ### Docker 部署
@@ -901,6 +1020,27 @@ npm start
这是 Cloudflare 的临时服务端错误,与代码无关。请在 Deployments 列表中重试部署即可。项目已内置 `wrangler.toml` 配置 `nodejs_compat` 兼容性标志。 这是 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 环境下 HTTPS 访问视频无法播放
如果你的网络使用 IPv6 访问,且通过路由器端口映射(如 20443 → 443),请确保: 如果你的网络使用 IPv6 访问,且通过路由器端口映射(如 20443 → 443),请确保:
@@ -916,12 +1056,14 @@ Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的
### IPTV 部分直播流无法播放 ### IPTV 部分直播流无法播放
如果你部署在 Vercel / Cloudflare 托管平台,IPTV 在合规模式下是**默认禁用**的;这不是 Bug,而是刻意限制。下面的说明只适用于 Docker / Node.js 自托管完整模式。
浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持: 浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer - RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer
- 某些加密或受 DRM 保护的流 - 某些加密或受 DRM 保护的流
- 需要特定客户端验证的流 - 需要特定客户端验证的流
KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。 在自托管完整模式下,KVideo 的 IPTV 代理会处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
### IPTV CCTV 等频道只有声音没有画面 ### IPTV CCTV 等频道只有声音没有画面
+5 -3
View File
@@ -6,7 +6,7 @@
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="true" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
@@ -26,9 +26,11 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="true" android:exported="true"
android:screenOrientation="landscape"> android:screenOrientation="landscape"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="adjustResize">
<!-- Leanback launcher (Android TV home) --> <!-- Leanback launcher (Android TV home) -->
<intent-filter> <intent-filter>
@@ -2,55 +2,67 @@ package com.kvideo.tv
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.util.Rational
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebChromeClient.CustomViewCallback
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.activity.ComponentActivity 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() { class MainActivity : ComponentActivity() {
companion object { companion object {
private const val PREFS_NAME = "kvideo_tv_settings" private const val PREFS_NAME = "kvideo_tv_settings"
private const val PREF_SERVER_URL = "server_url" private const val PREF_SERVER_URL = "server_url"
private const val TAG = "KVideoMainActivity"
} }
private lateinit var webView: WebView private lateinit var webView: WebView
private lateinit var setupContainer: View private lateinit var setupContainer: View
private lateinit var fullscreenContainer: FrameLayout
private lateinit var urlInput: EditText private lateinit var urlInput: EditText
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var openButton: Button private lateinit var openButton: Button
private lateinit var saveButton: Button private lateinit var saveButton: Button
private lateinit var prefs: android.content.SharedPreferences private lateinit var prefs: android.content.SharedPreferences
private var customView: View? = null
private var customViewCallback: CustomViewCallback? = null
private var wasSetupVisibleBeforeFullscreen = false
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Fullscreen immersive mode
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@Suppress("DEPRECATION") applyImmersiveMode()
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
)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
webView = findViewById(R.id.webview) webView = findViewById(R.id.webview)
setupContainer = findViewById(R.id.setup_container) setupContainer = findViewById(R.id.setup_container)
fullscreenContainer = findViewById(R.id.fullscreen_container)
urlInput = findViewById(R.id.url_input) urlInput = findViewById(R.id.url_input)
statusText = findViewById(R.id.status_text) statusText = findViewById(R.id.status_text)
openButton = findViewById(R.id.open_button) openButton = findViewById(R.id.open_button)
@@ -59,6 +71,24 @@ class MainActivity : ComponentActivity() {
saveButton.setOnClickListener { saveButton.setOnClickListener {
openConfiguredUrl() 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 { webView.apply {
setLayerType(View.LAYER_TYPE_HARDWARE, null) setLayerType(View.LAYER_TYPE_HARDWARE, null)
@@ -75,7 +105,41 @@ class MainActivity : ComponentActivity() {
} }
webViewClient = WebViewClient() 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() val configuredUrl = getConfiguredUrl()
@@ -88,6 +152,11 @@ class MainActivity : ComponentActivity() {
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { 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)) { if (!isSetupVisible() && (keyCode == KeyEvent.KEYCODE_MENU || keyCode == KeyEvent.KEYCODE_SETTINGS)) {
showSetup(getString(R.string.status_settings_hint)) showSetup(getString(R.string.status_settings_hint))
return true return true
@@ -111,6 +180,11 @@ class MainActivity : ComponentActivity() {
@Deprecated("Use OnBackPressedDispatcher") @Deprecated("Use OnBackPressedDispatcher")
override fun onBackPressed() { override fun onBackPressed() {
if (customView != null) {
exitCustomFullscreen()
return
}
if (isSetupVisible()) { if (isSetupVisible()) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
super.onBackPressed() 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() { override fun onDestroy() {
exitCustomFullscreen()
webView.destroy() webView.destroy()
super.onDestroy() super.onDestroy()
} }
@@ -210,4 +308,118 @@ class MainActivity : ComponentActivity() {
val scheme = uri.scheme?.lowercase() val scheme = uri.scheme?.lowercase()
return (scheme == "http" || scheme == "https") && !uri.host.isNullOrBlank() 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_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<LinearLayout <ScrollView
android:id="@+id/setup_container" android:id="@+id/setup_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#E6101115" android:background="#E6101115"
android:gravity="center" android:fillViewport="true">
android:orientation="vertical"
android:padding="32dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:background="#F31A1C22" android:gravity="center"
android:elevation="12dp"
android:maxWidth="680dp"
android:orientation="vertical" android:orientation="vertical"
android:padding="32dp"> 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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:background="#F31A1C22"
android:gravity="center_vertical" android:elevation="12dp"
android:orientation="horizontal"> android:maxWidth="680dp"
android:orientation="vertical"
android:padding="32dp">
<Button <TextView
android:id="@+id/open_button" android:layout_width="wrap_content"
android:layout_width="0dp" android:layout_height="wrap_content"
android:layout_height="56dp" android:text="@string/setup_title"
android:layout_weight="1" android:textColor="#FFFFFF"
android:text="@string/button_open_saved" android:textSize="30sp"
android:textAllCaps="false" 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" /> android:textSize="18sp" />
<Button <EditText
android:id="@+id/save_button" android:id="@+id/url_input"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginTop="24dp"
android:layout_weight="1" android:background="#FF0D0F14"
android:text="@string/button_save" android:hint="@string/setup_hint"
android:textAllCaps="false" android:importantForAutofill="no"
android:textSize="18sp" /> 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> </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>
</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> </FrameLayout>
+220
View File
@@ -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 判定以及全屏和画中画相关兼容性问题。"
]
}
]
}
+221
View File
@@ -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 }
);
}
}
+48 -49
View File
@@ -1,64 +1,63 @@
/** import { NextRequest, NextResponse } from 'next/server';
* Accounts API Route import {
* Returns account list (names + roles, no passwords) for admin visibility createManagedAccount,
*/ getPublicAuthConfig,
getServerSession,
import { NextResponse } from 'next/server'; isSuperAdminSession,
listAccountInfo,
} from '@/lib/server/auth';
export const runtime = 'edge'; export const runtime = 'edge';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; async function requireSuperAdmin(request: NextRequest) {
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; const session = await getServerSession(request);
const ACCOUNTS = process.env.ACCOUNTS || ''; if (!session) {
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
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' });
} }
// Add accounts from ACCOUNTS env var if (!isSuperAdminSession(session)) {
if (ACCOUNTS) { return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
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 } : {}) });
}
}
});
} }
return accounts; return { session };
} }
export async function GET() { export async function GET(request: NextRequest) {
const accounts = getAccountList(); const auth = await requireSuperAdmin(request);
if ('error' in auth) {
return auth.error;
}
const config = await getPublicAuthConfig();
const accounts = await listAccountInfo();
return NextResponse.json({ return NextResponse.json({
loginMode: config.loginMode,
managed: config.loginMode === 'managed',
accounts, accounts,
hasAdminPassword: !!effectiveAdminPassword,
hasAccounts: !!ACCOUNTS,
totalCount: accounts.length, 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
View File
@@ -1,145 +1,55 @@
/**
* Auth API Route
* Handles authentication with role-based accounts
*/
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import {
authenticateLogin,
createLoginResponse,
getPublicAuthConfig,
ManagedAuthStorageError,
validatePremiumAccess,
} from '@/lib/server/auth';
export const runtime = 'edge'; 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() { export async function GET() {
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS); try {
return NextResponse.json(await getPublicAuthConfig());
return NextResponse.json({ } catch (error) {
hasAuth, if (error instanceof ManagedAuthStorageError) {
hasPremiumAuth: !!PREMIUM_PASSWORD, return NextResponse.json(
persistSession: PERSIST_SESSION, { error: 'Managed authentication storage is unavailable' },
subscriptionSources: SUBSCRIPTION_SOURCES, { status: 503 },
iptvSources: IPTV_SOURCES, );
mergeSources: MERGE_SOURCES, }
danmakuApiUrl: DANMAKU_API_URL, throw error;
}); }
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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') { if (!password || typeof password !== 'string') {
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 }); return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
} }
// Premium password check (separate from main auth) const session = await authenticateLogin({ username, password });
if (type === 'premium') { if (!session) {
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 });
}
}
return NextResponse.json({ valid: false }); return NextResponse.json({ valid: false });
} }
// 1. Check admin password return createLoginResponse(session, request);
if (effectiveAdminPassword && password === effectiveAdminPassword) { } catch (error) {
const profileId = await generateProfileId(password); if (error instanceof ManagedAuthStorageError) {
return NextResponse.json({ return NextResponse.json(
valid: true, { valid: false, message: 'Managed authentication storage is unavailable' },
name: '管理员', { status: 503 },
role: 'super_admin', );
profileId,
persistSession: PERSIST_SESSION,
});
} }
// 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 }); return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
} }
} }
+26
View File
@@ -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);
}
+29 -29
View File
@@ -1,7 +1,15 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { buildDoubanImageCandidates } from '@/lib/server/douban-image';
export const runtime = 'edge'; 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) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get('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 }); return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
} }
try { let lastStatus = 502;
const imageResponse = await fetch(imageUrl, { let lastError = 'Error fetching image';
headers: {
'User-Agent': for (const candidate of buildDoubanImageCandidates(imageUrl)) {
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', let imageResponse: Response;
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
Referer: 'https://movie.douban.com/', try {
}, imageResponse = await fetch(candidate, { headers: REQUEST_HEADERS });
}); } catch {
// 线路不可达,换下一个镜像
continue;
}
if (!imageResponse.ok) { if (!imageResponse.ok) {
return NextResponse.json( lastStatus = imageResponse.status;
{ error: imageResponse.statusText }, lastError = imageResponse.statusText || 'Error fetching image';
{ status: imageResponse.status } continue;
);
} }
const contentType = imageResponse.headers.get('content-type');
if (!imageResponse.body) { if (!imageResponse.body) {
return NextResponse.json( lastStatus = 500;
{ error: 'Image response has no body' }, lastError = 'Image response has no body';
{ status: 500 } continue;
);
} }
// 创建响应头
const headers = new Headers(); const headers = new Headers();
const contentType = imageResponse.headers.get('content-type');
if (contentType) { if (contentType) {
headers.set('Content-Type', contentType); headers.set('Content-Type', contentType);
} }
// 设置缓存头
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
// 直接返回图片流
// @ts-ignore
return new Response(imageResponse.body, { return new Response(imageResponse.body, {
status: 200, status: 200,
headers, headers,
}); });
} catch (error) {
return NextResponse.json(
{ error: 'Error fetching image' },
{ status: 500 }
);
} }
return NextResponse.json({ error: lastError }, { status: lastStatus });
} }
+13
View File
@@ -6,6 +6,7 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -91,6 +92,18 @@ const CORS_HEADERS = {
}; };
export async function GET(request: NextRequest) { 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 url = request.nextUrl.searchParams.get('url');
const customUa = request.nextUrl.searchParams.get('ua'); const customUa = request.nextUrl.searchParams.get('ua');
const customReferer = request.nextUrl.searchParams.get('referer'); const customReferer = request.nextUrl.searchParams.get('referer');
+7 -43
View File
@@ -4,6 +4,7 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { probeSourceLatency } from '@/lib/api/source-latency';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -18,53 +19,16 @@ export async function POST(request: NextRequest) {
// Validate URL format // Validate URL format
try { 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 { } catch {
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 }); return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
} }
const startTime = performance.now(); const result = await probeSourceLatency(url);
return NextResponse.json(result);
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 });
}
}
} catch (error) { } catch (error) {
console.error('Ping error:', error); console.error('Ping error:', error);
return NextResponse.json( return NextResponse.json(
+115 -75
View File
@@ -8,105 +8,145 @@ import { NextRequest } from 'next/server';
import { getSourceById } from '@/lib/api/video-sources'; import { getSourceById } from '@/lib/api/video-sources';
import { getVideoDetail } from '@/lib/api/detail-api'; import { getVideoDetail } from '@/lib/api/detail-api';
import { fetchWithTimeout } from '@/lib/api/http-utils'; 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'; export const runtime = 'edge';
interface ProbeRequest { interface ProbeRequest {
id: string | number; id: string | number;
source: string; source: string;
episodeIndex?: number;
} }
function getResolutionLabel(width: number, height: number): { label: string; color: string } { function isValidSourceConfig(value: unknown): value is VideoSource {
const h = Math.min(width, height); // height is the shorter side if (!value || typeof value !== 'object') {
if (h >= 2160) return { label: '4K', color: 'bg-amber-500' }; return false;
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]) });
} }
if (resolutions.length === 0) return null;
// Return highest resolution const source = value as Partial<VideoSource>;
return resolutions.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; 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; id: string | number;
source: string; source: string;
resolution: { width: number; height: number; label: string; color: string } | null; episodeIndex?: number;
resolution: ResolutionProbeLabel | null;
resolutionOrigin: 'manifest' | 'hint';
}> { }> {
try { try {
const sourceConfig = getSourceById(video.source); const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source);
if (!sourceConfig) return { id: video.id, source: video.source, resolution: null }; if (!sourceConfig) {
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
}
// 1. Get detail to find first episode URL // 1. Get detail to find first episode URL
const detail = await getVideoDetail(video.id, sourceConfig); const detail = await getVideoDetail(video.id, sourceConfig);
if (!detail.episodes || detail.episodes.length === 0) { 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; const episodeIndex = typeof video.episodeIndex === 'number'
if (!firstUrl) return { id: video.id, source: video.source, resolution: null }; ? 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 // 2. Fetch the m3u8 manifest
let m3u8Content: string; let m3u8Content: string;
try { try {
const res = await fetchWithTimeout(firstUrl, { m3u8Content = await fetchManifestText(targetUrl, 8000);
headers: { 'User-Agent': 'Mozilla/5.0' },
}, 8000);
m3u8Content = await res.text();
} catch { } catch {
// Try with proxy return {
try { id: video.id,
const proxyUrl = new URL('/api/proxy', 'http://localhost'); source: video.source,
proxyUrl.searchParams.set('url', firstUrl); episodeIndex,
// Can't call our own proxy from edge easily, so just return null resolution: detailHint,
return { id: video.id, source: video.source, resolution: null }; resolutionOrigin: detailHint ? 'hint' : 'manifest',
} catch { };
return { id: video.id, source: video.source, resolution: null };
}
} }
// 3. Parse RESOLUTION from manifest const probed = await probeManifestResolution(targetUrl, m3u8Content, detailHint);
const res = parseResolutionFromM3u8(m3u8Content); return { id: video.id, source: video.source, episodeIndex, resolution: probed.resolution, resolutionOrigin: probed.origin };
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 } };
} catch { } 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 { try {
const body = await request.json(); const body = await request.json();
const videos: ProbeRequest[] = body.videos; const videos: ProbeRequest[] = body.videos;
const sourceConfigs = buildSourceConfigMap(body.sourceConfigs);
if (!Array.isArray(videos) || videos.length === 0) { if (!Array.isArray(videos) || videos.length === 0) {
return new Response(JSON.stringify({ error: 'Missing videos array' }), { 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, 100);
const batch = videos.slice(0, 30);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({
@@ -136,11 +176,11 @@ export async function POST(request: NextRequest) {
while (index < batch.length) { while (index < batch.length) {
const current = batch[index++]; const current = batch[index++];
try { try {
const result = await probeOne(current); const result = await probeOne(current, sourceConfigs);
const line = `data: ${JSON.stringify(result)}\n\n`; const line = `data: ${JSON.stringify(result)}\n\n`;
controller.enqueue(encoder.encode(line)); controller.enqueue(encoder.encode(line));
} catch { } 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`)); controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`));
} }
} }
@@ -160,7 +200,7 @@ export async function POST(request: NextRequest) {
'Connection': 'keep-alive', 'Connection': 'keep-alive',
}, },
}); });
} catch (error) { } catch {
return new Response(JSON.stringify({ error: 'Internal error' }), { return new Response(JSON.stringify({ error: 'Internal error' }), {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+13
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { processM3u8Content } from '@/lib/utils/proxy-utils'; import { processM3u8Content } from '@/lib/utils/proxy-utils';
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry'; import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -9,6 +10,18 @@ export const runtime = 'edge';
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
export async function GET(request: NextRequest) { 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'); const url = request.nextUrl.searchParams.get('url');
if (!url) { if (!url) {
+27 -8
View File
@@ -5,23 +5,36 @@
* so they persist across browsers, devices, and PWA installs. * so they persist across browsers, devices, and PWA installs.
*/ */
import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server'; 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'; export const runtime = 'edge';
const redis = Redis.fromEnv();
function redisKey(profileId: string): string { function redisKey(profileId: string): string {
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, ''); const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
return `user:config:${safe}`; 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) { 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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return authenticationRequiredResponse();
}
const redis = getRedisClient();
if (!redis) {
return syncUnavailableResponse();
} }
try { try {
@@ -37,10 +50,16 @@ export async function GET(request: NextRequest) {
} }
export async function POST(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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return authenticationRequiredResponse();
}
const redis = getRedisClient();
if (!redis) {
return syncUnavailableResponse();
} }
try { try {
@@ -48,7 +67,7 @@ export async function POST(request: NextRequest) {
const key = redisKey(profileId); const key = redisKey(profileId);
// Merge with existing data if present // 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() }; const merged = { ...(existing || {}), ...body, updatedAt: Date.now() };
await redis.set(key, merged); await redis.set(key, merged);
+27 -8
View File
@@ -1,16 +1,29 @@
import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server'; 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'; 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) { 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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return authenticationRequiredResponse();
}
const redis = getRedisClient();
if (!redis) {
return syncUnavailableResponse();
} }
try { try {
@@ -26,10 +39,16 @@ export async function GET(request: NextRequest) {
} }
export async function POST(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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return authenticationRequiredResponse();
}
const redis = getRedisClient();
if (!redis) {
return syncUnavailableResponse();
} }
try { try {
+15
View File
@@ -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
View File
@@ -1,14 +1,7 @@
import { VideoTogetherScript } from '@/components/VideoTogetherScript';
export default function IPTVLayout({ export default function IPTVLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return children;
<>
{children}
<VideoTogetherScript />
</>
);
} }
+28 -7
View File
@@ -11,11 +11,13 @@ import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
import { IPTVPlayer } from '@/components/iptv/IPTVPlayer'; import { IPTVPlayer } from '@/components/iptv/IPTVPlayer';
import { Icons } from '@/components/ui/Icon'; import { Icons } from '@/components/ui/Icon';
import { hasPermission, getSession } from '@/lib/store/auth-store'; import { hasPermission, getSession } from '@/lib/store/auth-store';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import Link from 'next/link'; import Link from 'next/link';
import type { M3UChannel } from '@/lib/utils/m3u-parser'; import type { M3UChannel } from '@/lib/utils/m3u-parser';
export default function IPTVPage() { 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 [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
const [showManager, setShowManager] = useState(false); const [showManager, setShowManager] = useState(false);
@@ -45,6 +47,17 @@ export default function IPTVPage() {
[visibleSources, cachedChannelsBySource] [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 auth is configured and user doesn't have iptv_access, show access denied
if (!canAccessIPTV && getSession()) { if (!canAccessIPTV && getSession()) {
return ( return (
@@ -59,12 +72,20 @@ export default function IPTVPage() {
); );
} }
// Auto-refresh on first load if we have sources but no cached channels if (!iptvEnabled) {
useEffect(() => { return (
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) { <div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)]">
refreshSources(); <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" />
}, [sources.length, cachedChannels.length, isLoading, refreshSources]); <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 ( return (
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed"> <div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
+62 -36
View File
@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { AutoSync } from '@/components/AutoSync'; // <-- 引入了自动同步组件 import { AutoSync } from '@/components/AutoSync'; // <-- 引入了自动同步组件
import { SiteIconProvider } from '@/components/SiteIconProvider';
import { TVProvider } from "@/lib/contexts/TVContext"; import { TVProvider } from "@/lib/contexts/TVContext";
import { TVNavigationInitializer } from "@/components/TVNavigationInitializer"; import { TVNavigationInitializer } from "@/components/TVNavigationInitializer";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
@@ -14,9 +14,16 @@ import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
import { BackToTop } from "@/components/ui/BackToTop"; import { BackToTop } from "@/components/ui/BackToTop";
import { ScrollPositionManager } from "@/components/ScrollPositionManager"; import { ScrollPositionManager } from "@/components/ScrollPositionManager";
import { LocaleProvider } from "@/components/LocaleProvider"; 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 fs from 'fs';
import path from 'path'; 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) // Server Component specifically for reading env/file (async for best practices)
async function AdKeywordsWrapper() { async function AdKeywordsWrapper() {
@@ -57,29 +64,31 @@ async function AdKeywordsWrapper() {
return <AdKeywordsInjector keywords={keywords} />; return <AdKeywordsInjector keywords={keywords} />;
} }
const geistSans = Geist({ export async function generateMetadata(): Promise<Metadata> {
variable: "--font-geist-sans", const siteIconSrc = await resolveSiteIconSrc();
subsets: ["latin"],
});
const geistMono = Geist_Mono({ return {
variable: "--font-geist-mono", title: siteConfig.title,
subsets: ["latin"], description: siteConfig.description,
}); icons: {
icon: siteIconSrc,
},
};
}
export const metadata: Metadata = { export default async function RootLayout({
title: siteConfig.title,
description: siteConfig.description,
icons: {
icon: '/icon.png',
},
};
export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; 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 ( return (
<html lang="zh-CN" suppressHydrationWarning> <html lang="zh-CN" suppressHydrationWarning>
<head> <head>
@@ -89,33 +98,50 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-capable" content="yes" /> <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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="KVideo" /> <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) */} {/* Theme Color (for browser address bar) */}
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
{/* Mobile viewport */} {/* Mobile viewport */}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head> </head>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className="antialiased"
suppressHydrationWarning suppressHydrationWarning
> >
<ThemeProvider> <SiteIconProvider iconSrc={siteIconSrc}>
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */} <ThemeProvider>
<AutoSync /> <RuntimeFeaturesProvider initialFeatures={runtimeFeatures}>
<LocaleProvider /> <VideoTogetherController
envEnabled={videoTogetherEnvEnabled}
scriptUrl={videoTogetherScriptUrl}
settingUrl={videoTogetherSettingUrl}
/>
<LocaleProvider />
<TVProvider> <TVProvider>
<TVNavigationInitializer /> <TVNavigationInitializer />
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}> <PasswordGate hasAuth={!!(
<AdKeywordsWrapper /> process.env.ADMIN_PASSWORD ||
{children} process.env.ACCOUNTS ||
<BackToTop /> process.env.ACCESS_PASSWORD ||
<ScrollPositionManager /> (
</PasswordGate> process.env.AUTH_SECRET &&
</TVProvider> process.env.UPSTASH_REDIS_REST_URL &&
<Analytics /> process.env.UPSTASH_REDIS_REST_TOKEN
<ServiceWorkerRegister /> )
</ThemeProvider> )}>
<AutoSync />
<AdKeywordsWrapper />
{children}
<BackToTop />
<ScrollPositionManager />
</PasswordGate>
</TVProvider>
{vercelAnalyticsEnabled ? <Analytics /> : null}
<ServiceWorkerRegister />
</RuntimeFeaturesProvider>
</ThemeProvider>
</SiteIconProvider>
{/* ARIA Live Region for Screen Reader Announcements */} {/* ARIA Live Region for Screen Reader Announcements */}
<div <div
+3 -5
View File
@@ -4,7 +4,6 @@ import { Suspense, useMemo } from 'react';
import { SearchForm } from '@/components/search/SearchForm'; import { SearchForm } from '@/components/search/SearchForm';
import { NoResults } from '@/components/search/NoResults'; import { NoResults } from '@/components/search/NoResults';
import { PopularFeatures } from '@/components/home/PopularFeatures'; import { PopularFeatures } from '@/components/home/PopularFeatures';
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
import { Navbar } from '@/components/layout/Navbar'; import { Navbar } from '@/components/layout/Navbar';
import { SearchResults } from '@/components/home/SearchResults'; import { SearchResults } from '@/components/home/SearchResults';
@@ -27,7 +26,9 @@ function HomePage() {
// Real-time latency pinging // Real-time latency pinging
const sourceUrls = useMemo(() => 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] [availableSources]
); );
@@ -85,9 +86,6 @@ function HomePage() {
{/* Favorites Sidebar - Left */} {/* Favorites Sidebar - Left */}
<FavoritesSidebar /> <FavoritesSidebar />
{/* Watch History Sidebar - Right */}
<WatchHistorySidebar />
</div> </div>
); );
} }
+1 -7
View File
@@ -1,5 +1,4 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { VideoTogetherScript } from '@/components/VideoTogetherScript';
export const metadata: Metadata = { export const metadata: Metadata = {
referrer: 'no-referrer', referrer: 'no-referrer',
@@ -10,10 +9,5 @@ export default function PlayerLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return children;
<>
{children}
<VideoTogetherScript />
</>
);
} }
+138 -105
View File
@@ -10,6 +10,7 @@ import { SourceInfo } from '@/components/player/EpisodeList';
import type { VideoSource } from '@/lib/types'; import type { VideoSource } from '@/lib/types';
import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution'; import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution';
import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe'; import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe';
import { setCachedResolution } from '@/lib/player/resolution-cache';
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer'; import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
import { useHistory } from '@/lib/store/history-store'; import { useHistory } from '@/lib/store/history-store';
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
@@ -44,6 +45,7 @@ function PlayerContent() {
// Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key) // Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key)
const groupedSourcesParam = searchParams.get('groupedSources'); const groupedSourcesParam = searchParams.get('groupedSources');
const gsKey = searchParams.get('gs'); const gsKey = searchParams.get('gs');
const missingRequiredParams = !videoId || !source;
// Track settings - use mode-specific store // Track settings - use mode-specific store
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; 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) // Sync with store changes if any (though usually it's one-way from UI to store)
useEffect(() => { useEffect(() => {
setIsReversed(modeStore.getSettings().episodeReverseOrder); setIsReversed(modeStore.getSettings().episodeReverseOrder);
}, []); }, [modeStore]);
useEffect(() => { useEffect(() => {
localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode); localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode);
@@ -86,17 +88,47 @@ function PlayerContent() {
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
} }
}, []); // Run once on mount }, [groupedSourcesParam, gsKey, router, searchParams]);
// Redirect if no video ID or source useEffect(() => {
if (!videoId || !source) { if (missingRequiredParams) {
router.push('/'); router.push('/');
return null; }
} }, [missingRequiredParams, router]);
// Handle auto-fallback when current source is unavailable (defined later, uses ref) const [pendingFallback, setPendingFallback] = useState(false);
const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined); const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
const pendingFallbackRef = useRef(false); 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 { const {
videoData, videoData,
@@ -108,17 +140,11 @@ function PlayerContent() {
setPlayUrl, setPlayUrl,
setVideoError, setVideoError,
fetchVideoDetails, fetchVideoDetails,
} = useVideoPlayer(videoId, source, episodeParam, isReversed, useCallback(() => { } = useVideoPlayer(videoId, source, episodeParam, isReversed, handleSourceUnavailable);
sourceUnavailableRef.current?.();
}, []));
// Parse grouped sources if available
const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
const groupedSources = useMemo<SourceInfo[]>(() => { const groupedSources = useMemo<SourceInfo[]>(() => {
let sources: SourceInfo[] = []; let sources: SourceInfo[] = [];
// Try sessionStorage cache first (new short URL), then fall back to URL param (legacy)
if (gsKey) { if (gsKey) {
const cached = retrieveGroupedSources(gsKey); const cached = retrieveGroupedSources(gsKey);
if (cached) sources = cached; if (cached) sources = cached;
@@ -130,72 +156,41 @@ function PlayerContent() {
} }
} }
// Merge in discovered sources (from background search)
if (discoveredSources.length > 0) { if (discoveredSources.length > 0) {
for (const ds of discoveredSources) { for (const ds of discoveredSources) {
if (!sources.find(s => s.source === ds.source)) { if (!sources.find((item) => item.source === ds.source)) {
sources.push(ds); sources.push(ds);
} }
} }
} }
// Always ensure the current source is in the list if (source && !sources.find((item) => item.source === source)) {
if (source && !sources.find(s => s.source === source)) {
sources.unshift({ sources.unshift({
id: videoId || '', id: videoId || '',
source: source, source,
sourceName: getSourceName(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; const fallbackPic = videoData?.vod_pic;
if (fallbackPic) { if (fallbackPic) {
sources = sources.map(s => s.pic ? s : { ...s, pic: fallbackPic }); sources = sources.map((item) => item.pic ? item : { ...item, pic: fallbackPic });
} }
return sources; 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 useEffect(() => {
sourceUnavailableRef.current = () => { groupedSourcesRef.current = groupedSources;
const alternatives = groupedSources.filter(s => s.source !== source); }, [groupedSources]);
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 });
};
// Retry pending fallback when discovered sources arrive // Retry pending fallback when discovered sources arrive
useEffect(() => { useEffect(() => {
if (pendingFallbackRef.current && discoveredSources.length > 0) { if (pendingFallback && discoveredSources.length > 0) {
sourceUnavailableRef.current?.(); handleSourceUnavailable();
} }
}, [discoveredSources]); }, [discoveredSources, handleSourceUnavailable, pendingFallback]);
// Background fetch alternative sources when none provided or when existing ones lack full info // Background fetch alternative sources when none provided or when existing ones lack full info
const fetchedSourcesRef = useRef(false); const fetchedSourcesRef = useRef(false);
@@ -211,7 +206,7 @@ function PlayerContent() {
try { existingSources = JSON.parse(groupedSourcesParam); } catch {} try { existingSources = JSON.parse(groupedSourcesParam); } catch {}
} }
// Always fetch alternatives if there's a pending fallback (source unavailable) // 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); existingSources.every(s => s.pic || s.latency !== undefined);
if (hasFullInfo) return; if (hasFullInfo) return;
@@ -255,7 +250,16 @@ function PlayerContent() {
const data = JSON.parse(line.slice(6)); const data = JSON.parse(line.slice(6));
if (data.type === 'videos' && data.videos) { if (data.type === 'videos' && data.videos) {
// Find exact or close title match // 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 v.vod_name?.toLowerCase().trim() === normalizedTitle
); );
if (match) { if (match) {
@@ -281,24 +285,43 @@ function PlayerContent() {
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [title, source, gsKey, groupedSourcesParam, isPremium]); }, [groupedSourcesParam, gsKey, isPremium, pendingFallback, source, title]);
// Track current source for switching // Track current source for switching
const [currentSourceId, setCurrentSourceId] = useState(source); const [currentSourceId, setCurrentSourceId] = useState(source);
const playerTimeRef = useRef(0); const playerTimeRef = useRef(0);
useEffect(() => {
setCurrentSourceId(source);
}, [source]);
// Track detected video resolution from the player // Track detected video resolution from the player
const [detectedResolution, setDetectedResolution] = useState<VideoResolutionInfo | null>(null); const [detectedResolution, setDetectedResolution] = useState<VideoResolutionInfo | null>(null);
// Probe resolution for all grouped sources (not just the playing one) // Probe resolution for all grouped sources (not just the playing one)
const probeList = useMemo(() => { const probeList = useMemo(() => {
return groupedSources.map(s => ({ id: s.id, source: s.source })); return groupedSources.map((item) => ({
}, [groupedSources]); id: item.id,
source: item.source,
episodeIndex: currentEpisode,
}));
}, [groupedSources, currentEpisode]);
const { resolutions: sourceResolutions } = useResolutionProbe(probeList); 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 // Add initial history entry when video data is loaded
useEffect(() => { useEffect(() => {
if (videoData && playUrl && videoId) { if (videoData && playUrl && videoId && source) {
// Map episodes to include index // Map episodes to include index
const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({ const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({
name: ep.name || `${idx + 1}`, name: ep.name || `${idx + 1}`,
@@ -321,7 +344,7 @@ function PlayerContent() {
} }
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]); }, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
const handleEpisodeClick = useCallback((episode: any, index: number) => { const handleEpisodeClick = useCallback((episode: { url: string }, index: number) => {
setCurrentEpisode(index); setCurrentEpisode(index);
setPlayUrl(episode.url); setPlayUrl(episode.url);
setVideoError(''); setVideoError('');
@@ -359,7 +382,7 @@ function PlayerContent() {
if (nextEpisode) { if (nextEpisode) {
handleEpisodeClick(nextEpisode, nextIndex); // handleEpisodeClick relies on state setters, which are stable 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 effectivePlayerViewportMode = useMemo<PlayerViewportMode>(() => {
const manualIndex = PLAYER_VIEWPORT_MODE_ORDER.indexOf(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.65fr)_minmax(300px,0.72fr)]'
: 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]'; : 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]';
if (missingRequiredParams) {
return null;
}
return ( return (
<div className="min-h-screen bg-[var(--bg-color)]"> <div className="min-h-screen bg-[var(--bg-color)]">
{/* Glass Navbar */} {/* Glass Navbar */}
<PlayerNavbar isPremium={isPremium} /> <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 ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20"> <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> <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} 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 */} {/* Video Player Section */}
<div className="lg:col-span-2 xl:col-span-1 space-y-6"> <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 className="sm:mx-0">
<div> <VideoPlayer
<div className="text-sm font-semibold text-[var(--text-color)]"> playUrl={playUrl}
videoId={videoId || undefined}
</div> currentEpisode={currentEpisode}
<div className="text-xs text-[var(--text-color-secondary)] mt-1"> onBack={() => router.back()}
totalEpisodes={videoData?.episodes?.length || 0}
{effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`} onNextEpisode={handleNextEpisode}
</div> isReversed={isReversed}
</div> isPremium={isPremium}
<SegmentedControl<PlayerViewportMode> videoTitle={videoData?.vod_name || title || ''}
options={[ episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
{ label: '标准', value: 'standard' }, externalTimeRef={playerTimeRef}
{ label: '宽屏', value: 'wide' }, onResolutionDetected={handleResolutionDetected}
{ label: '影院', value: 'cinema' },
]}
value={playerViewportMode}
onChange={setPlayerViewportMode}
className="min-w-[240px]"
/> />
</div> </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"> <div className="hidden lg:block">
<VideoMetadata <VideoMetadata
videoData={videoData} videoData={videoData}
@@ -461,9 +493,9 @@ function PlayerContent() {
)} )}
</div> </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: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 */} {/* Mobile Tabs */}
<SegmentedControl <SegmentedControl
options={[ options={[
@@ -530,6 +562,7 @@ function PlayerContent() {
</div> </div>
</div> </div>
</div> </div>
</div>
)} )}
</main> </main>
+15
View File
@@ -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[]>([]); const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
// Sources come from main settings store const syncFromStores = () => {
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
setPremiumSources(settings.premiumSources || []); const modeSettings = premiumModeSettingsStore.getSettings();
setLocale(settings.locale);
// Mode-specific settings come from premium mode settings store setPremiumSources(settings.premiumSources || []);
const modeSettings = premiumModeSettingsStore.getSettings(); setLocale(settings.locale);
setRealtimeLatency(modeSettings.realtimeLatency); setBlockedCategories(settings.blockedCategories || []);
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);
// blockedCategories is global setRealtimeLatency(modeSettings.realtimeLatency);
setBlockedCategories(settings.blockedCategories || []); 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) --- // --- Source management (uses main settingsStore) ---
+7
View File
@@ -5,6 +5,8 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings'; import { PremiumSourceSettings } from '@/components/settings/PremiumSourceSettings';
import { DisplaySettings } from '@/components/settings/DisplaySettings'; import { DisplaySettings } from '@/components/settings/DisplaySettings';
import { PlayerSettings } from '@/components/settings/PlayerSettings'; import { PlayerSettings } from '@/components/settings/PlayerSettings';
import { AppVersionSettings } from '@/components/settings/AppVersionSettings';
import { AccountSettings } from '@/components/settings/AccountSettings';
import { AdminGate } from '@/components/AdminGate'; import { AdminGate } from '@/components/AdminGate';
import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage'; import { usePremiumSettingsPage } from './hooks/usePremiumSettingsPage';
import Link from 'next/link'; import Link from 'next/link';
@@ -75,6 +77,11 @@ export default function PremiumSettingsPage() {
</div> </div>
</div> </div>
<AppVersionSettings />
{/* Account management and logout must remain available in premium mode. */}
<AccountSettings />
{/* Player Settings */} {/* Player Settings */}
<PlayerSettings <PlayerSettings
fullscreenType={fullscreenType} fullscreenType={fullscreenType}
+34 -16
View File
@@ -36,6 +36,7 @@ export function useSettingsPage() {
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS); const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
const [rememberScrollPosition, setRememberScrollPosition] = useState(true); const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
const [locale, setLocale] = useState<LocaleOption>('zh-CN'); const [locale, setLocale] = useState<LocaleOption>('zh-CN');
const [videoTogetherEnabled, setVideoTogetherEnabled] = useState(false);
// Danmaku settings // Danmaku settings
const [danmakuApiUrl, setDanmakuApiUrl] = useState(''); const [danmakuApiUrl, setDanmakuApiUrl] = useState('');
@@ -47,22 +48,28 @@ export function useSettingsPage() {
const [blockedCategories, setBlockedCategories] = useState<string[]>([]); const [blockedCategories, setBlockedCategories] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
const settings = settingsStore.getSettings(); const syncFromStore = () => {
setSources(settings.sources || []); const settings = settingsStore.getSettings();
setSubscriptions(settings.subscriptions || []); setSources(settings.sources || []);
setSortBy(settings.sortBy); setSubscriptions(settings.subscriptions || []);
setRealtimeLatency(settings.realtimeLatency); setSortBy(settings.sortBy);
setSearchDisplayMode(settings.searchDisplayMode); setRealtimeLatency(settings.realtimeLatency);
setFullscreenType(settings.fullscreenType); setSearchDisplayMode(settings.searchDisplayMode);
setProxyMode(settings.proxyMode); setFullscreenType(settings.fullscreenType);
setSeekStepSeconds(settings.seekStepSeconds); setProxyMode(settings.proxyMode);
setRememberScrollPosition(settings.rememberScrollPosition); setSeekStepSeconds(settings.seekStepSeconds);
setLocale(settings.locale); setRememberScrollPosition(settings.rememberScrollPosition);
setDanmakuApiUrl(settings.danmakuApiUrl); setLocale(settings.locale);
setDanmakuOpacity(settings.danmakuOpacity); setVideoTogetherEnabled(settings.videoTogetherEnabled);
setDanmakuFontSize(settings.danmakuFontSize); setDanmakuApiUrl(settings.danmakuApiUrl);
setDanmakuDisplayArea(settings.danmakuDisplayArea); setDanmakuOpacity(settings.danmakuOpacity);
setBlockedCategories(settings.blockedCategories || []); setDanmakuFontSize(settings.danmakuFontSize);
setDanmakuDisplayArea(settings.danmakuDisplayArea);
setBlockedCategories(settings.blockedCategories || []);
};
syncFromStore();
return settingsStore.subscribe(syncFromStore);
}, []); }, []);
const handleSourcesChange = (newSources: VideoSource[]) => { 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) => { const handleLocaleChange = (newLocale: LocaleOption) => {
setLocale(newLocale); setLocale(newLocale);
const currentSettings = settingsStore.getSettings(); const currentSettings = settingsStore.getSettings();
@@ -392,6 +408,8 @@ export function useSettingsPage() {
handleRememberScrollPositionChange, handleRememberScrollPositionChange,
locale, locale,
handleLocaleChange, handleLocaleChange,
videoTogetherEnabled,
handleVideoTogetherEnabledChange,
danmakuApiUrl, danmakuApiUrl,
handleDanmakuApiUrlChange, handleDanmakuApiUrlChange,
danmakuOpacity, danmakuOpacity,
+7
View File
@@ -11,6 +11,7 @@ import { AccountSettings } from '@/components/settings/AccountSettings';
import { DisplaySettings } from '@/components/settings/DisplaySettings'; import { DisplaySettings } from '@/components/settings/DisplaySettings';
import { PlayerSettings } from '@/components/settings/PlayerSettings'; import { PlayerSettings } from '@/components/settings/PlayerSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader'; import { SettingsHeader } from '@/components/settings/SettingsHeader';
import { AppVersionSettings } from '@/components/settings/AppVersionSettings';
import { UserSourceSettings } from '@/components/settings/UserSourceSettings'; import { UserSourceSettings } from '@/components/settings/UserSourceSettings';
import { UserDanmakuSettings } from '@/components/settings/UserDanmakuSettings'; import { UserDanmakuSettings } from '@/components/settings/UserDanmakuSettings';
import { PermissionGate } from '@/components/PermissionGate'; import { PermissionGate } from '@/components/PermissionGate';
@@ -57,7 +58,9 @@ export default function SettingsPage() {
handleProxyModeChange, handleProxyModeChange,
handleSeekStepSecondsChange, handleSeekStepSecondsChange,
rememberScrollPosition, rememberScrollPosition,
videoTogetherEnabled,
handleRememberScrollPositionChange, handleRememberScrollPositionChange,
handleVideoTogetherEnabledChange,
locale, locale,
handleLocaleChange, handleLocaleChange,
danmakuApiUrl, danmakuApiUrl,
@@ -78,6 +81,8 @@ export default function SettingsPage() {
{/* Header */} {/* Header */}
<SettingsHeader /> <SettingsHeader />
<AppVersionSettings />
{/* Account Settings */} {/* Account Settings */}
<AccountSettings /> <AccountSettings />
@@ -90,6 +95,8 @@ export default function SettingsPage() {
onProxyModeChange={handleProxyModeChange} onProxyModeChange={handleProxyModeChange}
seekStepSeconds={seekStepSeconds} seekStepSeconds={seekStepSeconds}
onSeekStepSecondsChange={handleSeekStepSecondsChange} onSeekStepSecondsChange={handleSeekStepSecondsChange}
videoTogetherEnabled={videoTogetherEnabled}
onVideoTogetherEnabledChange={handleVideoTogetherEnabledChange}
danmakuApiUrl={danmakuApiUrl} danmakuApiUrl={danmakuApiUrl}
onDanmakuApiUrlChange={handleDanmakuApiUrlChange} onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
danmakuOpacity={danmakuOpacity} danmakuOpacity={danmakuOpacity}
+55
View File
@@ -1,4 +1,8 @@
/* Video Player Components */ /* Video Player Components */
.kvideo-container {
container-type: inline-size;
}
.spinner { .spinner {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -435,3 +439,54 @@
cursor: pointer; cursor: pointer;
touch-action: manipulation; 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;
}
}
+4 -4
View File
@@ -8,10 +8,10 @@ import { useConfigSync } from '@/lib/hooks/useConfigSync';
import { getSession } from '@/lib/store/auth-store'; import { getSession } from '@/lib/store/auth-store';
// 防抖函数,防止频繁请求 // 防抖函数,防止频繁请求
function debounce(fn: Function, delay: number) { function debounce<Args extends unknown[]>(fn: (...args: Args) => void, delay: number) {
let timeoutId: NodeJS.Timeout; let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (...args: any[]) => { return (...args: Args) => {
clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay); timeoutId = setTimeout(() => fn(...args), delay);
}; };
} }
+271 -188
View File
@@ -1,232 +1,315 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; 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 { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store'; import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
import { useIPTVStore } from '@/lib/store/iptv-store'; import { useIPTVStore } from '@/lib/store/iptv-store';
import { Lock } from 'lucide-react';
/** type LoginMode = 'none' | 'legacy_password' | 'managed';
* Sync IPTV sources from environment variable.
* Format: JSON array [{name, url}] or comma-separated URLs.
*/
function syncIPTVSources(rawValue: string) { 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 {
try { const parsed = JSON.parse(rawValue);
const parsed = JSON.parse(rawValue); if (Array.isArray(parsed)) {
if (Array.isArray(parsed)) { entries = parsed.filter((item: unknown): item is { name: string; url: string } => {
entries = parsed.filter((item: any) => item && typeof item.url === 'string'); if (!item || typeof item !== 'object') return false;
} const candidate = item as { name?: unknown; url?: unknown };
} catch { return typeof candidate.url === 'string';
// 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,
}));
}
} }
} 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) { function syncMergeSources(rawValue: string) {
const enabled = rawValue === 'true' || rawValue === '1'; const enabled = rawValue === 'true' || rawValue === '1';
if (!enabled) return; if (!enabled) return;
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
if (settings.searchDisplayMode !== 'grouped') { if (settings.searchDisplayMode !== 'grouped') {
settingsStore.saveSettings({ settingsStore.saveSettings({
...settings, ...settings,
searchDisplayMode: 'grouped', searchDisplayMode: 'grouped',
}); });
} }
} }
function syncDanmakuApiUrl(rawValue: string) { function syncDanmakuApiUrl(rawValue: string) {
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return; if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
if (settings.danmakuApiUrl !== rawValue) { if (settings.danmakuApiUrl !== rawValue) {
settingsStore.saveSettings({ settingsStore.saveSettings({
...settings, ...settings,
danmakuApiUrl: rawValue, danmakuApiUrl: rawValue,
}); });
} }
} }
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) { function applyRuntimeConfig(data: {
// Enable background subscription syncing globally subscriptionSources?: string;
useSubscriptionSync(); iptvSources?: string;
mergeSources?: string;
danmakuApiUrl?: string;
}) {
if (data.subscriptionSources) {
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
}
const [isLocked, setIsLocked] = useState(true); if (data.iptvSources) {
const [password, setPassword] = useState(''); syncIPTVSources(data.iptvSources);
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);
useEffect(() => { if (data.mergeSources) {
let mounted = true; syncMergeSources(data.mergeSources);
}
const init = async () => { if (data.danmakuApiUrl) {
// Check if already has a valid session syncDanmakuApiUrl(data.danmakuApiUrl);
const session = getSession(); }
const isAuthenticated = !!session; }
// Initial fast check function toAuthSession(session: {
const localLocked = initialHasAuth && !isAuthenticated; accountId: string;
if (mounted) { profileId: string;
setIsLocked(localLocked); username?: string;
setIsClient(true); 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 export function PasswordGate({
try { children,
const res = await fetch('/api/auth'); hasAuth: initialHasAuth,
if (!res.ok) throw new Error('Failed to fetch auth config'); }: {
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) { useEffect(() => {
setHasAuth(data.hasAuth); let mounted = true;
setPersistSession(data.persistSession);
// Sync subscriptions const init = async () => {
if (data.subscriptionSources) { const mirroredSession = getSession();
settingsStore.syncEnvSubscriptions(data.subscriptionSources);
}
// Sync IPTV sources from env try {
if (data.iptvSources) { const [configRes, sessionRes] = await Promise.all([
syncIPTVSources(data.iptvSources); fetch('/api/auth'),
} fetch('/api/auth/session'),
]);
// Sync merge sources setting from env if (!configRes.ok) {
if (data.mergeSources) { throw new Error('Failed to fetch auth config');
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
} }
// Password didn't match const config = await configRes.json();
setError(true); const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null };
setIsValidating(false);
const form = document.getElementById('password-form'); if (!mounted) return;
form?.classList.add('animate-shake');
setTimeout(() => form?.classList.remove('animate-shake'), 500); 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 () => {
return <>{children}</>; 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 ( setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
<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)]"> setIsValidating(false);
<div className="w-full max-w-md p-4"> const form = document.getElementById('password-form');
<form form?.classList.add('animate-shake');
id="password-form" setTimeout(() => form?.classList.remove('animate-shake'), 500);
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"> if (!isClient) return null;
<h2 className="text-2xl font-bold">访</h2>
<p className="text-[var(--text-color-secondary)]">访</p>
</div>
<div className="w-full space-y-4"> if (!isLocked) {
<div className="space-y-2"> return <>{children}</>;
<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>
<button const showManagedFields = loginMode === 'managed';
type="submit"
disabled={isValidating} return (
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" <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">
{isValidating ? '验证中...' : '登录'} <form
</button> id="password-form"
</div> onSubmit={handleUnlock}
</form> 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> </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 { @keyframes shake {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); } 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; animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
} }
`}</style> `}</style>
</div> </div>
); );
} }
+10 -6
View File
@@ -7,7 +7,6 @@ const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked';
export function PremiumPasswordGate({ children }: { children: React.ReactNode }) { export function PremiumPasswordGate({ children }: { children: React.ReactNode }) {
const [isLocked, setIsLocked] = useState(true); const [isLocked, setIsLocked] = useState(true);
const [hasPremiumAuth, setHasPremiumAuth] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [isClient, setIsClient] = 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'; const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true';
try { try {
const res = await fetch('/api/auth'); const [configRes, sessionRes] = await Promise.all([
if (!res.ok) throw new Error('Failed to fetch auth config'); fetch('/api/auth'),
const data = await res.json(); 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) { if (mounted) {
setHasPremiumAuth(data.hasPremiumAuth);
// If no premium password configured, allow access // If no premium password configured, allow access
setIsLocked(data.hasPremiumAuth && !unlocked); setIsLocked(data.hasPremiumAuth && !unlocked && !isAdminSession);
setIsClient(true); setIsClient(true);
} }
} catch { } catch {
+33
View File
@@ -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);
}
+19 -9
View File
@@ -4,17 +4,27 @@ import { useEffect } from 'react';
export function ServiceWorkerRegister() { export function ServiceWorkerRegister() {
useEffect(() => { useEffect(() => {
if ('serviceWorker' in navigator) { if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then( const registerServiceWorker = () => {
(registration) => { navigator.serviceWorker.register('/sw.js')
// Registration successful .then((registration) => {
}) registration.update().catch(() => {
.catch((err) => { // Ignore update check errors.
// Registration failed
}); });
}); })
.catch(() => {
// Ignore registration failures.
});
};
if (document.readyState === 'complete') {
registerServiceWorker();
return;
} }
window.addEventListener('load', registerServiceWorker, { once: true });
return () => window.removeEventListener('load', registerServiceWorker);
}, []); }, []);
return null; return null;
+19
View File
@@ -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);
}
+140
View File
@@ -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;
}
-30
View File
@@ -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"
/>
</>
);
}
+109
View File
@@ -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 -2
View File
@@ -5,6 +5,7 @@
import type { FavoriteItem } from '@/lib/types'; import type { FavoriteItem } from '@/lib/types';
import { FavoritesItem } from './FavoritesItem'; import { FavoritesItem } from './FavoritesItem';
import { FavoritesEmptyState } from './FavoritesEmptyState'; import { FavoritesEmptyState } from './FavoritesEmptyState';
import { keepRenderableFavorites } from '@/lib/utils/sync-records';
interface FavoritesListProps { interface FavoritesListProps {
favorites: FavoriteItem[]; favorites: FavoriteItem[];
@@ -13,13 +14,15 @@ interface FavoritesListProps {
} }
export function FavoritesList({ favorites, onRemove, isPremium = false }: FavoritesListProps) { export function FavoritesList({ favorites, onRemove, isPremium = false }: FavoritesListProps) {
if (favorites.length === 0) { const renderableFavorites = keepRenderableFavorites(favorites);
if (renderableFavorites.length === 0) {
return <FavoritesEmptyState />; return <FavoritesEmptyState />;
} }
return ( return (
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-2 scroll-smooth"> <div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-2 scroll-smooth">
{favorites.map((item) => ( {renderableFavorites.map((item) => (
<FavoritesItem <FavoritesItem
key={`${item.source}:${item.videoId}`} key={`${item.source}:${item.videoId}`}
item={item} 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>
);
}
+5 -2
View File
@@ -1,6 +1,7 @@
import { HistoryItem } from './HistoryItem'; import { HistoryItem } from './HistoryItem';
import { HistoryEmptyState } from './HistoryEmptyState'; import { HistoryEmptyState } from './HistoryEmptyState';
import type { VideoHistoryItem } from '@/lib/types'; import type { VideoHistoryItem } from '@/lib/types';
import { keepRenderableHistory } from '@/lib/utils/sync-records';
interface HistoryListProps { interface HistoryListProps {
history: VideoHistoryItem[]; history: VideoHistoryItem[];
@@ -9,16 +10,18 @@ interface HistoryListProps {
} }
export function HistoryList({ history, onRemove, isPremium = false }: HistoryListProps) { export function HistoryList({ history, onRemove, isPremium = false }: HistoryListProps) {
const renderableHistory = keepRenderableHistory(history);
return ( return (
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{ <div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)', transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch' WebkitOverflowScrolling: 'touch'
}}> }}>
{history.length === 0 ? ( {renderableHistory.length === 0 ? (
<HistoryEmptyState /> <HistoryEmptyState />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{history.map((item) => ( {renderableHistory.map((item) => (
<HistoryItem <HistoryItem
key={item.showIdentifier} key={item.showIdentifier}
item={item} item={item}
+33 -32
View File
@@ -6,13 +6,21 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { TagManager } from './TagManager'; import { TagManager } from './TagManager';
import { MovieGrid } from './MovieGrid'; import { MovieGrid } from './MovieGrid';
import { useTagManager } from './hooks/useTagManager'; import { useTagManager } from './hooks/useTagManager';
import { usePopularMovies } from './hooks/usePopularMovies'; import { usePopularMovies } from './hooks/usePopularMovies';
import { usePersonalizedRecommendations } from './hooks/usePersonalizedRecommendations'; import { usePersonalizedRecommendations } from './hooks/usePersonalizedRecommendations';
interface DoubanMovie {
id: string;
title: string;
cover: string;
rate: string;
url: string;
}
interface PopularFeaturesProps { interface PopularFeaturesProps {
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
} }
@@ -46,19 +54,10 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
loadMoreRef: recommendLoadMoreRef, loadMoreRef: recommendLoadMoreRef,
} = usePersonalizedRecommendations(false); } = usePersonalizedRecommendations(false);
// Track whether the recommendation tab is active const [isRecommendSelected, setIsRecommendSelected] = useState(false);
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 effectiveRecommendSelected = hasHistory && isRecommendSelected; const effectiveRecommendSelected = hasHistory && isRecommendSelected;
const isTagManagementMode = showTagManager;
const { const {
movies, movies,
@@ -72,7 +71,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
contentType contentType
); );
const handleMovieClick = (movie: any) => { const handleMovieClick = (movie: DoubanMovie) => {
if (onSearch) { if (onSearch) {
onSearch(movie.title); onSearch(movie.title);
} }
@@ -94,7 +93,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */} {/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
{!effectiveRecommendSelected && ( {!isTagManagementMode && !effectiveRecommendSelected && (
<div className="mb-10 flex justify-center"> <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"> <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 */} {/* Sliding Indicator */}
@@ -145,24 +144,26 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
} : undefined} } : undefined}
/> />
{effectiveRecommendSelected ? ( {!isTagManagementMode && (
<MovieGrid effectiveRecommendSelected ? (
movies={recommendMovies} <MovieGrid
loading={recommendLoading} movies={recommendMovies}
hasMore={recommendHasMore} loading={recommendLoading}
onMovieClick={handleMovieClick} hasMore={recommendHasMore}
prefetchRef={recommendPrefetchRef} onMovieClick={handleMovieClick}
loadMoreRef={recommendLoadMoreRef} prefetchRef={recommendPrefetchRef}
/> loadMoreRef={recommendLoadMoreRef}
) : ( />
<MovieGrid ) : (
movies={movies} <MovieGrid
loading={loading} movies={movies}
hasMore={hasMore} loading={loading}
onMovieClick={handleMovieClick} hasMore={hasMore}
prefetchRef={prefetchRef} onMovieClick={handleMovieClick}
loadMoreRef={loadMoreRef} prefetchRef={prefetchRef}
/> loadMoreRef={loadMoreRef}
/>
)
)} )}
</div> </div>
); );
+2
View File
@@ -51,6 +51,7 @@ export function SortableTag({
> >
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}> <div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
<button <button
type="button"
onClick={() => onTagSelect(tag.id)} onClick={() => onTagSelect(tag.id)}
className={` className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none 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> </button>
{showTagManager && ( {showTagManager && (
<button <button
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onTagDelete(tag.id); onTagDelete(tag.id);
-2
View File
@@ -1,7 +1,5 @@
'use client'; 'use client';
import { Icons } from '@/components/ui/Icon';
interface TagInputProps { interface TagInputProps {
newTagInput: string; newTagInput: string;
onNewTagInputChange: (value: string) => void; onNewTagInputChange: (value: string) => void;
+8 -2
View File
@@ -15,6 +15,7 @@ import {
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
horizontalListSortingStrategy, horizontalListSortingStrategy,
rectSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { SortableTag, Tag } from './SortableTag'; import { SortableTag, Tag } from './SortableTag';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
@@ -115,12 +116,17 @@ export function TagList({
> >
<div <div
ref={scrollContainerRef} 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 */} {/* Recommendation Tag — non-draggable, rendered before sortable tags */}
{recommendTag && ( {recommendTag && (
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<button <button
type="button"
onClick={recommendTag.onSelect} onClick={recommendTag.onSelect}
className={` 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 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 <SortableContext
items={tags.map((t) => t.id)} items={tags.map((t) => t.id)}
strategy={horizontalListSortingStrategy} strategy={showTagManager ? rectSortingStrategy : horizontalListSortingStrategy}
> >
{tags.map((tag) => ( {tags.map((tag) => (
<SortableTag <SortableTag
+26 -18
View File
@@ -1,19 +1,32 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { DragEndEvent } from '@dnd-kit/core'; import { DragEndEvent } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
import type { Tag } from '../SortableTag';
const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' }; const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' };
const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_'; 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() { export function useTagManager() {
const [contentType, setContentType] = useState<'movie' | 'tv'>(() => { const [contentType, setContentType] = useState<'movie' | 'tv'>(() => {
if (typeof window === 'undefined') return 'movie'; if (typeof window === 'undefined') return 'movie';
const saved = localStorage.getItem('kvideo_default_content_type'); const saved = localStorage.getItem('kvideo_default_content_type');
return saved === 'tv' ? 'tv' : 'movie'; return saved === 'tv' ? 'tv' : 'movie';
}); });
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value); const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.id);
const [tags, setTags] = useState<any[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [isLoadingTags, setIsLoadingTags] = useState(false); const [isLoadingTags, setIsLoadingTags] = useState(false);
const [newTagInput, setNewTagInput] = useState(''); const [newTagInput, setNewTagInput] = useState('');
const [showTagManager, setShowTagManager] = useState(false); const [showTagManager, setShowTagManager] = useState(false);
@@ -32,7 +45,8 @@ export function useTagManager() {
const saved = localStorage.getItem(storageKey); const saved = localStorage.getItem(storageKey);
if (saved) { if (saved) {
try { try {
setTags(JSON.parse(saved)); const parsedTags = JSON.parse(saved);
setTags(Array.isArray(parsedTags) ? ensureDefaultTag(parsedTags) : [DEFAULT_TAG]);
return; return;
} catch (e) { } catch (e) {
console.error('Failed to parse saved tags', e); console.error('Failed to parse saved tags', e);
@@ -51,12 +65,7 @@ export function useTagManager() {
value: label, value: label,
})); }));
// If "热门" isn't in the list, add it to the front setTags(ensureDefaultTag(mappedTags));
if (!mappedTags.some((t: any) => t.value === '热门')) {
mappedTags.unshift(DEFAULT_TAG);
}
setTags(mappedTags);
// Also save to localStorage to avoid repeated fetches if desired // Also save to localStorage to avoid repeated fetches if desired
// Actually, let's just keep them in memory for now unless they customize // Actually, let's just keep them in memory for now unless they customize
} else { } else {
@@ -71,10 +80,10 @@ export function useTagManager() {
}; };
loadTags(); loadTags();
setSelectedTag(DEFAULT_TAG.value); setSelectedTag(DEFAULT_TAG.id);
}, [contentType, storageKey]); }, [contentType, storageKey]);
const saveTags = (newTags: any[]) => { const saveTags = (newTags: Tag[]) => {
setTags(newTags); setTags(newTags);
localStorage.setItem(storageKey, JSON.stringify(newTags)); localStorage.setItem(storageKey, JSON.stringify(newTags));
}; };
@@ -92,9 +101,11 @@ export function useTagManager() {
}; };
const handleDeleteTag = (tagId: string) => { const handleDeleteTag = (tagId: string) => {
if (tagId === DEFAULT_TAG.id) return;
saveTags(tags.filter(t => t.id !== tagId)); saveTags(tags.filter(t => t.id !== tagId));
if (selectedTag === tagId) { if (selectedTag === tagId) {
setSelectedTag('popular'); setSelectedTag(DEFAULT_TAG.id);
} }
}; };
@@ -111,19 +122,16 @@ export function useTagManager() {
label, label,
value: label, value: label,
})); }));
if (!mappedTags.some((t: any) => t.value === '热门')) { setTags(ensureDefaultTag(mappedTags));
mappedTags.unshift(DEFAULT_TAG);
}
setTags(mappedTags);
} else { } else {
setTags([DEFAULT_TAG]); setTags([DEFAULT_TAG]);
} }
} catch (error) { } catch {
setTags([DEFAULT_TAG]); setTags([DEFAULT_TAG]);
} finally { } finally {
setIsLoadingTags(false); setIsLoadingTags(false);
} }
setSelectedTag(DEFAULT_TAG.value); setSelectedTag(DEFAULT_TAG.id);
setShowTagManager(false); setShowTagManager(false);
}; };
+115 -22
View File
@@ -13,6 +13,7 @@ import { Icons } from '@/components/ui/Icon';
import type { M3UChannel } from '@/lib/utils/m3u-parser'; import type { M3UChannel } from '@/lib/utils/m3u-parser';
import type { IPTVSource } from '@/lib/store/iptv-store'; import type { IPTVSource } from '@/lib/store/iptv-store';
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-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']> = { const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
enableWorker: true, enableWorker: true,
@@ -91,6 +92,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
const [isLive, setIsLive] = useState(true); const [isLive, setIsLive] = useState(true);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = 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 [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [showVolumeSlider, setShowVolumeSlider] = 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 // Whether we have multi-source data
const hasMultiSource = channelsBySource && sources && sources.length > 0; 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 // Get current route URL
const routes = channel.routes || [channel.url]; const routes = channel.routes || [channel.url];
@@ -167,20 +175,31 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
const onPlay = () => setIsPlaying(true); const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false); const onPause = () => setIsPlaying(false);
const onTimeUpdate = () => { const onTimeUpdate = () => {
const range = getSeekRange(video);
setCurrentTime(video.currentTime); setCurrentTime(video.currentTime);
const dur = video.duration; setSeekWindow(range);
if (isFinite(dur) && dur > 0) { if (range) {
setDuration(dur); setDuration(range.duration);
setIsLive(false); setIsLive(false);
} else { } else {
const dur = video.duration;
if (isFinite(dur) && dur > 0) {
setDuration(dur);
}
setIsLive(true); setIsLive(true);
} }
}; };
const onDurationChange = () => { const onDurationChange = () => {
const dur = video.duration; const range = getSeekRange(video);
if (isFinite(dur) && dur > 0) { setSeekWindow(range);
setDuration(dur); if (range) {
setDuration(range.duration);
setIsLive(false); setIsLive(false);
} else {
const dur = video.duration;
if (isFinite(dur) && dur > 0) {
setDuration(dur);
}
} }
}; };
const onVolumeChange = () => { const onVolumeChange = () => {
@@ -212,6 +231,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
setIsLive(true); setIsLive(true);
setCurrentTime(0); setCurrentTime(0);
setDuration(0); setDuration(0);
setSeekWindow(null);
// Clean up previous // Clean up previous
if (hlsRef.current) { if (hlsRef.current) {
@@ -310,7 +330,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
hlsProxy.attachMedia(video); hlsProxy.attachMedia(video);
// Filter HEVC levels for proxy attempt too // Filter HEVC levels for proxy attempt too
hlsProxy.on(Hls.Events.MANIFEST_PARSED, (_, data) => { hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => {
filterHEVCLevels(hlsProxy); filterHEVCLevels(hlsProxy);
markLoaded(); markLoaded();
video.play().catch(() => {}); 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) // First try initial URL (direct or proxied based on custom headers)
hls.loadSource(initialUrl); hls.loadSource(initialUrl);
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
// Filter HEVC levels to prevent audio-only playback // Filter HEVC levels to prevent audio-only playback
filterHEVCLevels(hls); filterHEVCLevels(hls);
markLoaded(); markLoaded();
@@ -469,14 +489,19 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
}; };
const progressPercent = useMemo(() => { const progressPercent = useMemo(() => {
const video = videoRef.current; if (seekWindow) {
const seekRange = video ? getSeekRange(video) : null; return Math.max(0, Math.min(100, ((currentTime - seekWindow.start) / seekWindow.duration) * 100));
if (seekRange) {
return Math.max(0, Math.min(100, ((currentTime - seekRange.start) / seekRange.duration) * 100));
} }
if (!duration) return 0; if (!duration) return 0;
return Math.max(0, Math.min(100, (currentTime / duration) * 100)); 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 () => { const toggleFullscreen = async () => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -567,24 +592,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
const isSearchMode = sidebarSearch.trim().length > 0; const isSearchMode = sidebarSearch.trim().length > 0;
// Toggle source expansion // Toggle source expansion
const toggleSource = (sourceId: string) => { const toggleSource = useCallback((sourceId: string) => {
setExpandedSources(prev => { setExpandedSources(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(sourceId)) next.delete(sourceId); if (next.has(sourceId)) next.delete(sourceId);
else next.add(sourceId); else next.add(sourceId);
return next; return next;
}); });
}; }, []);
// Toggle group expansion // Toggle group expansion
const toggleGroup = (key: string) => { const toggleGroup = useCallback((key: string) => {
setExpandedGroups(prev => { setExpandedGroups(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) next.delete(key); if (next.has(key)) next.delete(key);
else next.add(key); else next.add(key);
return next; 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 // Render a channel button
const renderChannelButton = (ch: M3UChannel, i: number) => { const renderChannelButton = (ch: M3UChannel, i: number) => {
@@ -623,21 +659,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
// Render multi-level sidebar content // Render multi-level sidebar content
const renderMultiLevelSidebar = () => { const renderMultiLevelSidebar = () => {
if (!channelsBySource || !sources) return null; if (!channelsBySource || !sources) return null;
const orderedSources = activeSourceId
? [
...sources.filter((source) => source.id === activeSourceId),
...sources.filter((source) => source.id !== activeSourceId),
]
: sources;
return ( return (
<div className="p-1"> <div className="p-1">
{sources.map(source => { {orderedSources.map(source => {
const sourceData = channelsBySource[source.id]; const sourceData = channelsBySource[source.id];
if (!sourceData || sourceData.channels.length === 0) return null; if (!sourceData || sourceData.channels.length === 0) return null;
const isExpanded = expandedSources.has(source.id); 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 ( return (
<div key={source.id} className="mb-1"> <div key={source.id} className="mb-1">
{/* Source Header */} {/* Source Header */}
<button <button
onClick={(e) => { e.stopPropagation(); toggleSource(source.id); }} 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"> <div className="flex items-center gap-2 min-w-0">
<Icons.TV size={14} className="flex-shrink-0 text-[var(--accent-color)]" /> <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 */} {/* Source Content */}
{isExpanded && ( {isExpanded && (
<div className="ml-2 border-l border-white/10 pl-1"> <div className="ml-2 border-l border-white/10 pl-1">
{sourceData.groups.length > 0 ? ( {orderedGroups.length > 0 ? (
// Has groups — show group-level // Has groups — show group-level
sourceData.groups.map(group => { orderedGroups.map(group => {
const groupKey = `${source.id}::${group}`; const groupKey = `${source.id}::${group}`;
const groupExpanded = expandedGroups.has(groupKey); const groupExpanded = expandedGroups.has(groupKey);
const groupChannels = sourceData.channels.filter(ch => ch.group === group); const groupChannels = sourceData.channels.filter(ch => ch.group === group);
const isActiveGroup = groupKey === activeGroupKey;
return ( return (
<div key={groupKey} className="mb-0.5"> <div key={groupKey} className="mb-0.5">
<button <button
onClick={(e) => { e.stopPropagation(); toggleGroup(groupKey); }} 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"> <div className="flex items-center gap-1.5 min-w-0">
<Icons.Tag size={12} className="flex-shrink-0" /> <Icons.Tag size={12} className="flex-shrink-0" />
@@ -734,6 +789,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
<div <div
ref={containerRef} ref={containerRef}
className="fixed inset-0 z-[9999] bg-black flex" className="fixed inset-0 z-[9999] bg-black flex"
style={{ cursor: shouldHideCursor ? 'none' : undefined }}
onMouseMove={resetControlsTimeout} onMouseMove={resetControlsTimeout}
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest('[data-controls]') || (e.target as HTMLElement).closest('[data-sidebar]')) return; 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>
</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> </div>
{/* Sidebar Content */} {/* Sidebar Content */}
+30 -15
View File
@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { useSiteIcon } from '@/components/SiteIconProvider';
import { Icons } from '@/components/ui/Icon'; import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config'; import { siteConfig } from '@/lib/config/site-config';
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store'; import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import { LogOut } from 'lucide-react'; import { LogOut } from 'lucide-react';
interface NavbarProps { interface NavbarProps {
@@ -16,16 +18,20 @@ interface NavbarProps {
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings'; const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
const [session, setSessionState] = useState<AuthSession | null>(null); const favoritesHref = isPremiumMode ? '/premium/favorites' : '/favorites';
const [session] = useState<AuthSession | null>(() => getSession());
useEffect(() => { const { iptvEnabled } = useRuntimeFeatures();
setSessionState(getSession()); const siteIconSrc = useSiteIcon();
}, []);
const handleLogout = () => { const handleLogout = () => {
clearSession(); fetch('/api/auth/session', { method: 'DELETE' })
// Navigate to root to clear search query params .catch(() => {
window.location.href = '/'; // Best effort only.
})
.finally(() => {
clearSession();
window.location.href = '/';
});
}; };
return ( 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"> <div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
<Image <Image
src="/icon.png" src={siteIconSrc}
alt={siteConfig.name} alt={siteConfig.name}
width={40} width={40}
height={40} height={40}
unoptimized
className="object-contain" className="object-contain"
/> />
</div> </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"> <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 */} {/* IPTV Link - only show if user has iptv_access or no auth configured */}
{hasPermission('iptv_access') && ( {iptvEnabled && hasPermission('iptv_access') && (
<Link <Link
href="/iptv" 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" 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 */} {/* User Info */}
{session && ( {session && (
<div className="hidden sm:flex items-center gap-2"> <div className="flex items-center gap-1 sm: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="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)]"> <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)} {session.name.charAt(0)}
</div> </div>
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span> <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"> <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> </span>
)} )}
</div> </div>
@@ -106,6 +113,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
> >
<Icons.Github size={20} /> <Icons.Github size={20} />
</a> </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 <Link
href={settingsHref} 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" 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"
+187 -43
View File
@@ -3,6 +3,13 @@
import React, { useRef, useEffect, useCallback } from 'react'; import React, { useRef, useEffect, useCallback } from 'react';
import type { DanmakuComment } from '@/lib/types/danmaku'; import type { DanmakuComment } from '@/lib/types/danmaku';
import { settingsStore } from '@/lib/store/settings-store'; import { settingsStore } from '@/lib/store/settings-store';
import {
clampDanmakuY,
haveDanmakuCanvasMetricsChanged,
resolveDanmakuCanvasMetrics,
scaleDanmakuCoordinate,
type DanmakuCanvasMetrics,
} from '@/lib/player/danmaku-canvas-utils';
interface DanmakuCanvasProps { interface DanmakuCanvasProps {
comments: DanmakuComment[]; comments: DanmakuComment[];
@@ -12,7 +19,7 @@ interface DanmakuCanvasProps {
} }
interface ActiveDanmaku { interface ActiveDanmaku {
comment: DanmakuComment; comment: DanmakuComment & { _expiry?: number };
x: number; x: number;
y: number; y: number;
speed: 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 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 TOP_BOTTOM_DURATION = 4; // seconds for top/bottom comments to stay visible
const MAX_LANES = 20; 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 canvasRef = useRef<HTMLCanvasElement>(null);
const activeRef = useRef<ActiveDanmaku[]>([]); const activeRef = useRef<ActiveDanmaku[]>([]);
const lastTimeRef = useRef(currentTime); const lastTimeRef = useRef(currentTime);
@@ -33,43 +90,127 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
const rafRef = useRef<number>(0); const rafRef = useRef<number>(0);
const lastSpawnTimeRef = useRef(-1); const lastSpawnTimeRef = useRef(-1);
const laneSlotsRef = useRef<number[]>(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free 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 settingsSnapshot = React.useSyncExternalStore(
const [opacity, setOpacity] = React.useState(0.7); subscribeToDanmakuSettings,
const [fontSize, setFontSize] = React.useState(20); getDanmakuSettingsSnapshot,
const [displayArea, setDisplayArea] = React.useState(0.5); () => DEFAULT_DANMAKU_SETTINGS_SNAPSHOT,
);
const { opacity, fontSize, displayArea } = React.useMemo(
() => parseDanmakuSettingsSnapshot(settingsSnapshot),
[settingsSnapshot],
);
useEffect(() => { const syncCanvasSize = useCallback(() => {
const s = settingsStore.getSettings(); const canvas = canvasRef.current;
setOpacity(s.danmakuOpacity); if (!canvas) return null;
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;
}, []);
// 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(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const resize = () => { let rafId: number | null = null;
const rect = canvas.getBoundingClientRect(); let timeoutIds: number[] = [];
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr; const clearScheduledResize = () => {
canvas.height = rect.height * dpr; if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId);
}
timeoutIds = [];
}; };
resize(); const scheduleResize = () => {
const observer = new ResizeObserver(resize); syncCanvasSize();
observer.observe(canvas); clearScheduledResize();
return () => observer.disconnect();
}, []); 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) // Clear on seek (when currentTime jumps significantly)
useEffect(() => { useEffect(() => {
@@ -88,9 +229,11 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || !comments.length) return; if (!canvas || !comments.length) return;
const rect = canvas.getBoundingClientRect(); const metrics = metricsRef.current ?? syncCanvasSize();
const canvasWidth = rect.width; if (!metrics) return;
const effectiveHeight = rect.height * displayArea;
const canvasWidth = metrics.width;
const effectiveHeight = metrics.height * displayArea;
const laneHeight = fontSize * LANE_HEIGHT_FACTOR; const laneHeight = fontSize * LANE_HEIGHT_FACTOR;
// Find comments in the time window [lastSpawn, time] // 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; : effectiveHeight - bestLane * laneHeight - fontSize * 0.4;
activeRef.current.push({ activeRef.current.push({
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any, comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION },
x: (canvasWidth - textWidth) / 2, x: (canvasWidth - textWidth) / 2,
y, y,
speed: 0, speed: 0,
@@ -175,7 +318,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
} }
lastSpawnTimeRef.current = windowEnd; lastSpawnTimeRef.current = windowEnd;
}, [comments, fontSize, displayArea]); }, [comments, displayArea, fontSize, syncCanvasSize]);
// Animation loop // Animation loop
useEffect(() => { useEffect(() => {
@@ -186,17 +329,18 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
const dpr = window.devicePixelRatio || 1; const metrics = metricsRef.current ?? syncCanvasSize();
const rect = canvas.getBoundingClientRect(); if (!metrics) {
const w = rect.width; rafRef.current = requestAnimationFrame(animate);
const h = rect.height; return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!isPlaying) { if (!isPlaying) {
// When paused, still draw active comments frozen in place // When paused, still draw active comments frozen in place
ctx.save(); ctx.save();
ctx.scale(dpr, dpr); ctx.scale(metrics.dpr, metrics.dpr);
ctx.globalAlpha = opacity; ctx.globalAlpha = opacity;
ctx.font = `bold ${fontSize}px sans-serif`; ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -235,7 +379,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
} }
} else { } else {
// Top/bottom: remove when expired // Top/bottom: remove when expired
const expiry = (d.comment as any)._expiry || 0; const expiry = d.comment._expiry || 0;
if (currentTime < expiry) { if (currentTime < expiry) {
newActive.push(d); newActive.push(d);
} }
@@ -245,7 +389,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
// Draw // Draw
ctx.save(); ctx.save();
ctx.scale(dpr, dpr); ctx.scale(metrics.dpr, metrics.dpr);
ctx.globalAlpha = opacity; ctx.globalAlpha = opacity;
ctx.font = `bold ${fontSize}px sans-serif`; ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -268,7 +412,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (rafRef.current) cancelAnimationFrame(rafRef.current);
lastRafTimeRef.current = 0; lastRafTimeRef.current = 0;
}; };
}, [isPlaying, currentTime, opacity, fontSize, spawnComments]); }, [isPlaying, currentTime, opacity, fontSize, spawnComments, syncCanvasSize]);
return ( return (
<canvas <canvas
+18 -4
View File
@@ -16,6 +16,7 @@ import { useIsIOS, useIsMobile } from '@/lib/hooks/mobile/useDeviceDetection';
import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap'; import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap';
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store'; import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
import { shouldHidePlayerCursor } from '@/lib/player/cursor-visibility';
import './web-fullscreen.css'; import './web-fullscreen.css';
type WebFullscreenSize = 'full' | 'large' | 'focused'; type WebFullscreenSize = 'full' | 'large' | 'focused';
@@ -312,9 +313,22 @@ export function DesktopVideoPlayer({
}; };
}, [data.fullscreenMode, shouldForceLandscape, viewportMetrics, webFullscreenSize]); }, [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' const stageClassName = data.fullscreenMode === 'window'
? 'kvideo-stage kvideo-web-fullscreen-stage' ? 'kvideo-stage kvideo-web-fullscreen-stage'
: 'kvideo-stage absolute inset-0'; : 'kvideo-stage absolute inset-0';
const isTopAlignedWebFullscreen = data.fullscreenMode === 'window' && isMobile && !isLandscape && !shouldForceLandscape;
// Mobile double-tap gesture for skip forward/backward // Mobile double-tap gesture for skip forward/backward
const { handleTap } = useDoubleTap({ const { handleTap } = useDoubleTap({
@@ -341,15 +355,15 @@ export function DesktopVideoPlayer({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={`kvideo-container relative aspect-video bg-black rounded-[var(--radius-2xl)] group ${data.fullscreenMode === 'window' ? 'is-web-fullscreen' : '' className={`kvideo-container relative aspect-video bg-black group ${data.fullscreenMode === 'window' ? 'is-web-fullscreen' : ''
} ${shouldForceLandscape ? 'force-landscape' : ''}`} } ${shouldForceLandscape ? 'force-landscape' : ''} ${isTopAlignedWebFullscreen ? 'top-align-stage' : ''} overflow-hidden rounded-none sm:rounded-[var(--radius-2xl)]`}
style={webFullscreenStyle} style={containerStyle}
onMouseMove={() => { handleMouseMove(); }} onMouseMove={() => { handleMouseMove(); }}
onMouseLeave={() => isPlaying && setShowControls(false)} onMouseLeave={() => isPlaying && setShowControls(false)}
> >
<div className={stageClassName}> <div className={stageClassName}>
{/* Clipping Wrapper for video and overlays - Restores the 'Liquid Glass' rounded look */} {/* 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"> <div className="absolute inset-0 pointer-events-auto">
{/* Video Element */} {/* Video Element */}
+272 -160
View File
@@ -9,9 +9,10 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
import { settingsStore } from '@/lib/store/settings-store'; import { settingsStore } from '@/lib/store/settings-store';
import { extractQualityLabel } from '@/lib/utils/video';
import type { VideoResolutionInfo } from './hooks/useVideoResolution'; import type { VideoResolutionInfo } from './hooks/useVideoResolution';
import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe'; 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 { interface Episode {
name?: string; name?: string;
@@ -66,8 +67,14 @@ export function EpisodeList({
}: EpisodeListProps) { }: EpisodeListProps) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [sourceExpanded, setSourceExpanded] = useState(false); const [sourceExpanded, setSourceExpanded] = useState(false);
const [showAllSources, setShowAllSources] = 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 // Source latency state
const [latencies, setLatencies] = useState<Record<string, number>>({}); const [latencies, setLatencies] = useState<Record<string, number>>({});
@@ -77,18 +84,14 @@ export function EpisodeList({
// Helper: get best resolution badge for a source // Helper: get best resolution badge for a source
const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => { 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 probeKey = `${source.source}:${source.id}`;
const probed = sourceResolutions?.[probeKey]; return getSourceResolutionBadge({
if (probed) { isCurrent,
return { label: probed.label, color: probed.color }; currentResolution: currentResolution || undefined,
} probedResolution: sourceResolutions?.[probeKey] || undefined,
// Fall back to quality label parsed from remarks cachedResolution: getCachedResolution(source.source, source.id) || undefined,
return extractQualityLabel(source.remarks) || null; remarks: source.remarks,
});
}, [currentResolution, sourceResolutions]); }, [currentResolution, sourceResolutions]);
// Current source info // Current source info
@@ -97,21 +100,47 @@ export function EpisodeList({
return sources.find(s => s.source === currentSource) || null; return sources.find(s => s.source === currentSource) || null;
}, [sources, currentSource]); }, [sources, currentSource]);
useEffect(() => {
if (sourceSectionCollapsed) {
setSourceExpanded(false);
}
}, [sourceSectionCollapsed]);
// Sort sources by latency // 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(() => { const sortedSources = useMemo(() => {
if (!sources) return []; if (!sources) return [];
return [...sources].sort((a, b) => { return [...sources].sort((a, b) => {
const latA = latencies[a.source] ?? a.latency ?? Infinity; const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity;
const latB = latencies[b.source] ?? b.latency ?? Infinity; const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity;
return latA - latB; 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 // Resolve source ID to its actual baseUrl for pinging
const getSourcePingUrl = useCallback((sourceId: string): string | null => { const getSourcePingUrl = useCallback((sourceId: string): string | null => {
@@ -127,16 +156,7 @@ export function EpisodeList({
// Initialize latencies from sources // Initialize latencies from sources
useEffect(() => { useEffect(() => {
if (!sources) return; if (!sources) return;
const initial: Record<string, number> = {}; const hasMissing = sources.some((source) => source.latency === undefined);
let hasMissing = false;
sources.forEach(s => {
if (s.latency !== undefined) {
initial[s.source] = s.latency;
} else {
hasMissing = true;
}
});
setLatencies(initial);
// Auto-refresh latencies for sources that don't have them // Auto-refresh latencies for sources that don't have them
if (hasMissing && sources.length > 1) { if (hasMissing && sources.length > 1) {
@@ -214,6 +234,46 @@ export function EpisodeList({
return isReversed ? [...episodes].reverse() : episodes; return isReversed ? [...episodes].reverse() : episodes;
}, [episodes, isReversed]); }, [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 // Map display index to original index
const getOriginalIndex = useCallback((displayIndex: number) => { const getOriginalIndex = useCallback((displayIndex: number) => {
if (!episodes || !isReversed) return displayIndex; if (!episodes || !isReversed) return displayIndex;
@@ -283,7 +343,7 @@ export function EpisodeList({
<button <button
onClick={() => { onClick={() => {
if (!sourceSectionCollapsed) { 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'}`} 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 && ( {!sourceSectionCollapsed && (
<Icons.ChevronDown <Icons.ChevronDown
size={16} 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> </button>
@@ -331,11 +391,11 @@ export function EpisodeList({
</div> </div>
{/* Expanded source list */} {/* Expanded source list */}
{!sourceSectionCollapsed && sourceExpanded && ( {isSourceListOpen && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{(() => { {(() => {
const MAX_VISIBLE = 5; 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; const hasMoreSources = sortedSources.length > MAX_VISIBLE;
// Group sources by typeName // Group sources by typeName
@@ -360,12 +420,14 @@ export function EpisodeList({
)} )}
{typeSources.map((source, index) => { {typeSources.map((source, index) => {
const isCurrent = source.source === currentSource; 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 globalIndex = sortedSources.indexOf(source);
const badge = getResBadge(source, isCurrent);
return ( return (
<button <button
key={`${source.source}-${index}`} key={`${source.source}-${index}`}
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
onClick={() => { onClick={() => {
if (!isCurrent) { if (!isCurrent) {
onSourceChange!(source); onSourceChange!(source);
@@ -382,35 +444,33 @@ export function EpisodeList({
`} `}
aria-current={isCurrent ? 'true' : undefined} 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)]">
<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
<Image src={source.pic || '/placeholder-poster.svg'}
src={source.pic} alt=""
alt="" width={40}
width={40} height={56}
height={56} className="w-full h-full object-cover"
className="w-full h-full object-cover" unoptimized
unoptimized referrerPolicy="no-referrer"
referrerPolicy="no-referrer" onError={(e) => {
onError={(e) => { const target = e.currentTarget as HTMLImageElement;
(e.currentTarget as HTMLImageElement).style.display = 'none'; if (target.dataset.fallback === '1') return;
}} target.dataset.fallback = '1';
/> target.src = '/placeholder-poster.svg';
</div> }}
)} />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate flex items-center gap-1.5"> <div className="font-medium text-sm truncate flex items-center gap-1.5">
{source.sourceName || source.source} {source.sourceName || source.source}
{(() => { {badge ? (
const badge = getResBadge(source, isCurrent); <span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
return badge ? ( {badge.label}
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}> </span>
{badge.label} ) : null}
</span>
) : null;
})()}
</div> </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> <div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
)} )}
{latency !== undefined && ( {latency !== undefined && (
@@ -441,11 +501,13 @@ export function EpisodeList({
) : ( ) : (
visibleSources.map((source, index) => { visibleSources.map((source, index) => {
const isCurrent = source.source === currentSource; 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 ( return (
<button <button
key={`${source.source}-${index}`} key={`${source.source}-${index}`}
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
onClick={() => { onClick={() => {
if (!isCurrent) { if (!isCurrent) {
onSourceChange!(source); onSourceChange!(source);
@@ -462,35 +524,33 @@ export function EpisodeList({
`} `}
aria-current={isCurrent ? 'true' : undefined} 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)]">
<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
<Image src={source.pic || '/placeholder-poster.svg'}
src={source.pic} alt=""
alt="" width={40}
width={40} height={56}
height={56} className="w-full h-full object-cover"
className="w-full h-full object-cover" unoptimized
unoptimized referrerPolicy="no-referrer"
referrerPolicy="no-referrer" onError={(e) => {
onError={(e) => { const target = e.currentTarget as HTMLImageElement;
(e.currentTarget as HTMLImageElement).style.display = 'none'; if (target.dataset.fallback === '1') return;
}} target.dataset.fallback = '1';
/> target.src = '/placeholder-poster.svg';
</div> }}
)} />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate flex items-center gap-1.5"> <div className="font-medium text-sm truncate flex items-center gap-1.5">
{source.sourceName || source.source} {source.sourceName || source.source}
{(() => { {badge ? (
const badge = getResBadge(source, isCurrent); <span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
return badge ? ( {badge.label}
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}> </span>
{badge.label} ) : null}
</span>
) : null;
})()}
</div> </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> <div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
)} )}
{latency !== undefined && ( {latency !== undefined && (
@@ -520,10 +580,10 @@ export function EpisodeList({
</div> </div>
{hasMoreSources && ( {hasMoreSources && (
<button <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" 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" /></> <> <Icons.ChevronDown size={12} className="rotate-180" /></>
) : ( ) : (
<> ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></> <> ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
@@ -538,40 +598,59 @@ export function EpisodeList({
</div> </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" /> <Icons.List size={20} className="sm:w-6 sm:h-6" />
<span></span> <span></span>
{episodes && ( {episodes && (
<Badge variant="primary">{episodes.length}</Badge> <Badge variant="primary">{episodes.length}</Badge>
)} )}
{/* Reverse order toggle button - only show when more than 1 episode */} <div className="ml-auto flex items-center gap-1.5">
{showReverseToggle && !episodeSectionCollapsed && ( {/* 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 <button
onClick={() => onToggleReverse?.(!isReversed)} onClick={() => onEpisodeSectionCollapseChange?.(!episodeSectionCollapsed)}
className={` 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"
ml-auto p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 aria-label={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
${isReversed title={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
? '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} /> <Icons.ChevronDown
size={16}
className={`transition-transform duration-200 ${episodeSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
/>
</button> </button>
)} </div>
<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 ? ( {episodeSectionCollapsed ? (
@@ -584,59 +663,92 @@ export function EpisodeList({
</div> </div>
</div> </div>
) : ( ) : (
<div <div className="space-y-3">
ref={listRef} {/* Section page chips for long episode lists */}
className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2" {episodeLayout === 'grid' && totalEpisodePages > 1 && (
role="radiogroup" <div className="flex flex-wrap gap-1.5">
aria-label="剧集选择" {pageRangeLabels.map((label, page) => (
>
{displayEpisodes && displayEpisodes.length > 0 ? (
displayEpisodes.map((episode, displayIndex) => {
const originalIndex = getOriginalIndex(displayIndex);
const isCurrentEpisode = currentEpisode === originalIndex;
return (
<button <button
key={originalIndex} key={label}
ref={(el) => { buttonRefs.current[displayIndex] = el; }} onClick={() => setEpisodePage(page)}
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={` 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 px-2.5 py-1 rounded-[var(--radius-2xl)] text-xs font-medium transition-all duration-200 cursor-pointer
${isCurrentEpisode ${episodePage === page
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110' ? 'bg-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]' : '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"> {label}
<span className="font-medium text-sm sm:text-base">
{episode.name || `${originalIndex + 1}`}
</span>
{isCurrentEpisode && (
<Icons.Play size={16} />
)}
</div>
</button> </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>
)} )}
<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> </div>
)} )}
</Card> </Card>
+4 -1
View File
@@ -3,11 +3,13 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { ThemeSwitcher } from '@/components/ThemeSwitcher'; import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { useSiteIcon } from '@/components/SiteIconProvider';
import { Icons } from '@/components/ui/Icon'; import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config'; import { siteConfig } from '@/lib/config/site-config';
export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) { export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
const router = useRouter(); const router = useRouter();
const siteIconSrc = useSiteIcon();
return ( return (
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4" style={{ transform: 'translateZ(0)' }}> <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 ? "返回高级主页" : "返回首页"} title={isPremium ? "返回高级主页" : "返回首页"}
> >
<Image <Image
src="/icon.png" src={siteIconSrc}
alt={siteConfig.name} alt={siteConfig.name}
width={40} width={40}
height={40} height={40}
unoptimized
className="object-contain" className="object-contain"
/> />
</button> </button>
+25 -7
View File
@@ -24,13 +24,31 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
return ( return (
<Card hover={false}> <Card hover={false}>
<div className="flex flex-col sm:flex-row items-start gap-4"> <div className="flex flex-col sm:flex-row items-start gap-4">
{videoData?.vod_pic && ( <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">
<img {videoData?.vod_pic ? (
src={videoData.vod_pic} <img
alt={videoData.vod_name} src={videoData.vod_pic}
className="w-24 h-36 sm:w-32 sm:h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]" 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"> <div className="flex-1">
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-[var(--text-color)] mb-3"> <h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-[var(--text-color)] mb-3">
{videoData?.vod_name || title} {videoData?.vod_name || title}
+10 -44
View File
@@ -2,13 +2,11 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { useHistory } from '@/lib/store/history-store'; 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 { CustomVideoPlayer } from './CustomVideoPlayer';
import { VideoPlayerError } from './VideoPlayerError'; import { VideoPlayerError } from './VideoPlayerError';
import { VideoPlayerEmpty } from './VideoPlayerEmpty'; import { VideoPlayerEmpty } from './VideoPlayerEmpty';
import { usePlayerSettings } from './hooks/usePlayerSettings';
interface VideoPlayerProps { interface VideoPlayerProps {
playUrl: string; playUrl: string;
@@ -53,38 +51,8 @@ export function VideoPlayer({
const durationRef = useRef(0); const durationRef = useRef(0);
const SAVE_INTERVAL = 5000; // 5 seconds throttle const SAVE_INTERVAL = 5000; // 5 seconds throttle
// Get showModeIndicator setting const { showModeIndicator, proxyMode } = usePlayerSettings(isPremium);
// Get showModeIndicator and proxyMode settings const effectiveUseProxy = proxyMode === 'always' ? true : proxyMode === 'none' ? false : useProxy;
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]);
// Use reactive hook to subscribe to history updates // 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) // 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. // 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); setUseProxy(true);
setShouldAutoPlay(true); // Force autoplay after proxy retry setShouldAutoPlay(true); // Force autoplay after proxy retry
setVideoError(''); setVideoError('');
@@ -200,10 +168,10 @@ export function VideoPlayer({
// Let's toggle it to give best chance. // Let's toggle it to give best chance.
// Actually requirement says "try no proxy and proxy and same as before". // Actually requirement says "try no proxy and proxy and same as before".
// So simple toggle is fine. // 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 ? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
: playUrl; : playUrl;
@@ -212,16 +180,15 @@ export function VideoPlayer({
} }
return ( return (
<div data-no-spatial> <div data-no-spatial className="relative">
<Card hover={false} className="p-0 relative">
{/* Mode Indicator Badge - controlled by settings */} {/* Mode Indicator Badge - controlled by settings */}
{showModeIndicator && ( {showModeIndicator && (
<div className="absolute top-3 right-3 z-30"> <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-orange-500/80 text-white'
: 'bg-green-500/80 text-white' : 'bg-green-500/80 text-white'
}`}> }`}>
{useProxy ? '代理模式' : '直连模式'} {effectiveUseProxy ? '代理模式' : '直连模式'}
</span> </span>
</div> </div>
)} )}
@@ -235,7 +202,7 @@ export function VideoPlayer({
/> />
) : ( ) : (
<CustomVideoPlayer <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} src={finalPlayUrl}
onError={handleVideoError} onError={handleVideoError}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
@@ -251,7 +218,6 @@ export function VideoPlayer({
onResolutionDetected={onResolutionDetected} onResolutionDetected={onResolutionDetected}
/> />
)} )}
</Card>
</div> </div>
); );
} }
@@ -73,8 +73,8 @@ export function DesktopControls(props: DesktopControlsProps) {
/> />
{/* Controls Bar */} {/* Controls Bar */}
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2"> <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="flex items-center justify-between gap-4"> <div className="player-controls-row flex min-w-0 items-center justify-between gap-4">
<DesktopLeftControls {...props} formatTime={formatTime} /> <DesktopLeftControls {...props} formatTime={formatTime} />
<DesktopRightControls {...props} /> <DesktopRightControls {...props} />
</div> </div>
@@ -32,30 +32,33 @@ export function DesktopLeftControls({
formatTime formatTime
}: DesktopLeftControlsProps) { }: DesktopLeftControlsProps) {
return ( 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 */} {/* Play/Pause */}
<button <button
onClick={onTogglePlay} onClick={onTogglePlay}
className="btn-icon" className="btn-icon shrink-0"
aria-label={isPlaying ? 'Pause' : 'Play'} aria-label={isPlaying ? 'Pause' : 'Play'}
> >
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />} {isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
</button> </button>
{/* Volume */} {/* Volume */}
<DesktopVolumeControl <div className="player-volume-control shrink-0">
volumeBarRef={volumeBarRef} <DesktopVolumeControl
volume={volume} volumeBarRef={volumeBarRef}
isMuted={isMuted} volume={volume}
showVolumeBar={showVolumeBar} isMuted={isMuted}
onToggleMute={onToggleMute} showVolumeBar={showVolumeBar}
onVolumeChange={onVolumeChange} onToggleMute={onToggleMute}
onVolumeMouseDown={onVolumeMouseDown} onVolumeChange={onVolumeChange}
/> onVolumeMouseDown={onVolumeMouseDown}
/>
</div>
{/* Time */} {/* Time */}
<span className="text-white text-sm font-medium tabular-nums"> <span className="player-time-display min-w-0 truncate text-sm font-medium text-white tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)} <span>{formatTime(currentTime)}</span>
<span className="player-duration-display"> / {formatTime(duration)}</span>
</span> </span>
</div> </div>
); );
+2 -1
View File
@@ -84,6 +84,7 @@ export function DesktopOverlay({
}: DesktopOverlayProps) { }: DesktopOverlayProps) {
// Show navigation buttons when controls are visible or when paused (controls usually show when paused anyway) // Show navigation buttons when controls are visible or when paused (controls usually show when paused anyway)
const showNavButtons = showControls || !isPlaying; const showNavButtons = showControls || !isPlaying;
const showFullscreenClock = showControls || !isPlaying;
return ( return (
<> <>
@@ -106,7 +107,7 @@ export function DesktopOverlay({
{isFullscreen && fullscreenClock && ( {isFullscreen && fullscreenClock && (
<div <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' }} 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)]"> <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 onShowCastMenu
}: DesktopRightControlsProps) { }: DesktopRightControlsProps) {
return ( 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 */} {/* Picture-in-Picture */}
{ {
isPiPSupported && ( isPiPSupported && (
<button <button
onClick={onTogglePictureInPicture} onClick={onTogglePictureInPicture}
className="btn-icon" className="btn-icon shrink-0"
aria-label="画中画" aria-label="画中画"
title="画中画" title="画中画"
> >
@@ -49,7 +49,7 @@ export function DesktopRightControls({
isAirPlaySupported && ( isAirPlaySupported && (
<button <button
onClick={onShowAirPlayMenu} onClick={onShowAirPlayMenu}
className="btn-icon" className="btn-icon shrink-0"
aria-label="隔空播放" aria-label="隔空播放"
title="隔空播放" title="隔空播放"
> >
@@ -63,7 +63,7 @@ export function DesktopRightControls({
isCastAvailable && ( isCastAvailable && (
<button <button
onClick={onShowCastMenu} onClick={onShowCastMenu}
className="btn-icon" className="btn-icon shrink-0"
aria-label="投屏" aria-label="投屏"
title="投屏" title="投屏"
> >
@@ -75,17 +75,19 @@ export function DesktopRightControls({
{/* Web Fullscreen */} {/* Web Fullscreen */}
<button <button
onClick={onToggleWebFullscreen} onClick={onToggleWebFullscreen}
className="btn-icon" className="btn-icon shrink-0"
aria-label={isWebFullscreen ? '退出网页全屏' : '网页全屏'} aria-label={isWebFullscreen ? '退出网页全屏' : '网页全屏'}
title={isWebFullscreen ? '退出网页全屏 (W)' : '网页全屏 (W)'} 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> </button>
{/* Native Fullscreen */} {/* Native Fullscreen */}
<button <button
onClick={onToggleNativeFullscreen} onClick={onToggleNativeFullscreen}
className="btn-icon" className="btn-icon shrink-0"
aria-label={isNativeFullscreen ? '退出系统全屏' : '系统全屏'} aria-label={isNativeFullscreen ? '退出系统全屏' : '系统全屏'}
title={isNativeFullscreen ? '退出系统全屏 (F)' : '系统全屏 (F)'} 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 { interface Window {
chrome: any; chrome: any;
cast: any; cast: any;
__onGCastApiAvailable: (isAvailable: boolean) => void; __onGCastApiAvailable?: (isAvailable: boolean) => void;
} }
} }
@@ -24,69 +24,37 @@ export function useCastControls({
setIsCasting setIsCasting
}: UseCastControlsProps) { }: UseCastControlsProps) {
const castContextRef = useRef<any>(null); const castContextRef = useRef<any>(null);
const loadMediaRef = useRef<() => void>(() => {});
useEffect(() => { const isCastSdkReady = useCallback(() => {
// Function to initialize Cast if (typeof window === 'undefined') return false;
const initializeCastApi = () => {
if (!window.cast || !window.cast.framework) return;
const castContext = window.cast.framework.CastContext.getInstance(); return Boolean(
castContextRef.current = castContext; window.cast?.framework?.CastContext?.getInstance &&
window.cast?.framework?.CastContextEventType?.SESSION_STATE_CHANGED &&
castContext.setOptions({ window.cast?.framework?.SessionState &&
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, window.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID &&
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED window.chrome?.cast?.media?.MediaInfo &&
}); window.chrome?.cast?.media?.LoadRequest &&
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]);
const loadMedia = useCallback(() => { 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 castContext = castContextRef.current;
const session = castContext.getCurrentSession(); const session = castContext.getCurrentSession();
if (!session) return; 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 // Handle HLS specifically if possible, though DEFAULT_MEDIA_RECEIVER supports it
if (src.includes('.m3u8')) { if (src.includes('.m3u8')) {
mediaInfo.contentType = 'application/x-mpegurl'; mediaInfo.contentType = 'application/x-mpegurl';
} }
const request = new window.chrome.cast.media.LoadRequest(mediaInfo); const request = new castMedia.LoadRequest(mediaInfo);
// Sync current time // Sync current time
if (videoRef.current) { if (videoRef.current) {
@@ -99,11 +67,108 @@ export function useCastControls({
); );
}, [src, videoRef]); }, [src, videoRef]);
const showCastMenu = useCallback(() => { useEffect(() => {
if (window.cast && window.cast.framework) { loadMediaRef.current = loadMedia;
window.cast.framework.CastContext.getInstance().requestSession(); }, [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(() => ({ const castActions = useMemo(() => ({
showCastMenu showCastMenu
@@ -1,5 +1,76 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { FullscreenMode } from '../useDesktopPlayerState'; 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 { interface UseFullscreenControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
@@ -26,10 +97,13 @@ export function useFullscreenControls({
setIsAirPlaySupported, setIsAirPlaySupported,
fullscreenType = 'native' fullscreenType = 'native'
}: UseFullscreenControlsProps) { }: UseFullscreenControlsProps) {
const androidPiPSessionRef = useRef<AndroidPiPSessionState | null>(null);
const lockLandscape = useCallback(async () => { 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 { try {
await (window.screen as any).orientation.lock('landscape'); await orientation.lock('landscape');
} catch (error) { } catch (error) {
console.warn('Orientation lock failed:', error); console.warn('Orientation lock failed:', error);
} }
@@ -37,46 +111,65 @@ export function useFullscreenControls({
}, []); }, []);
const unlockOrientation = useCallback(() => { 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 { try {
(window.screen as any).orientation.unlock(); orientation.unlock();
} catch { } catch {
// Ignore unlock errors from unsupported browsers. // Ignore unlock errors from unsupported browsers.
} }
} }
}, []); }, []);
const getNativeFullscreenElement = useCallback(() => ( const getNativeFullscreenElement = useCallback(() => {
document.fullscreenElement || const fullscreenDocument = getFullscreenDocument();
(document as any).webkitFullscreenElement || return (
(document as any).mozFullScreenElement || fullscreenDocument.fullscreenElement ||
(document as any).msFullscreenElement 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(() => { useEffect(() => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const hasNativePiP = 'pictureInPictureEnabled' in document; const fullscreenDocument = getFullscreenDocument();
const hasWebkitPiP = videoRef.current && ( const video = videoRef.current as PiPCapableVideoElement | null;
'webkitSupportsPresentationMode' in (videoRef.current as any) || const hasNativePiP = Boolean(fullscreenDocument.pictureInPictureEnabled);
'webkitPresentationMode' in (videoRef.current as any) const hasWebkitPiP = Boolean(video && (
); typeof video.webkitSupportsPresentationMode === 'function' ||
setIsPiPSupported(hasNativePiP || !!hasWebkitPiP); typeof video.webkitPresentationMode === 'string'
));
const androidBridge = getAndroidPiPBridge();
const hasAndroidPiPBridge = Boolean(androidBridge?.isPictureInPictureSupported?.());
setIsPiPSupported(hasNativePiP || !!hasWebkitPiP || hasAndroidPiPBridge);
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window); setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window);
} }
}, [setIsPiPSupported, setIsAirPlaySupported, videoRef]); }, [getAndroidPiPBridge, setIsPiPSupported, setIsAirPlaySupported, videoRef]);
const exitNativeFullscreen = useCallback(async () => { const exitNativeFullscreen = useCallback(async () => {
const fullscreenDocument = getFullscreenDocument();
try { try {
if (document.exitFullscreen) { if (fullscreenDocument.exitFullscreen) {
await document.exitFullscreen(); await fullscreenDocument.exitFullscreen();
} else if ((document as any).webkitExitFullscreen) { } else if (fullscreenDocument.webkitExitFullscreen) {
await (document as any).webkitExitFullscreen(); await fullscreenDocument.webkitExitFullscreen();
} else if ((document as any).mozCancelFullScreen) { } else if (fullscreenDocument.mozCancelFullScreen) {
await (document as any).mozCancelFullScreen(); await fullscreenDocument.mozCancelFullScreen();
} else if ((document as any).msExitFullscreen) { } else if (fullscreenDocument.msExitFullscreen) {
await (document as any).msExitFullscreen(); await fullscreenDocument.msExitFullscreen();
} }
} catch (error) { } catch (error) {
console.error('Failed to exit fullscreen:', error); console.error('Failed to exit fullscreen:', error);
@@ -104,23 +197,25 @@ export function useFullscreenControls({
}, [exitNativeFullscreen, fullscreenMode, lockLandscape, setFullscreenMode, setIsFullscreen]); }, [exitNativeFullscreen, fullscreenMode, lockLandscape, setFullscreenMode, setIsFullscreen]);
const enterNativeFullscreen = useCallback(async () => { 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') { if (fullscreenMode === 'window') {
exitWindowFullscreen(); exitWindowFullscreen();
} }
try { try {
if (containerRef.current.requestFullscreen) { if (container.requestFullscreen) {
await containerRef.current.requestFullscreen(); await container.requestFullscreen();
} else if ((containerRef.current as any).webkitRequestFullscreen) { } else if (container.webkitRequestFullscreen) {
await (containerRef.current as any).webkitRequestFullscreen(); await container.webkitRequestFullscreen();
} else if ((containerRef.current as any).mozRequestFullScreen) { } else if (container.mozRequestFullScreen) {
await (containerRef.current as any).mozRequestFullScreen(); await container.mozRequestFullScreen();
} else if ((containerRef.current as any).msRequestFullscreen) { } else if (container.msRequestFullscreen) {
await (containerRef.current as any).msRequestFullscreen(); await container.msRequestFullscreen();
} else if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { } else if (video?.webkitEnterFullscreen) {
(videoRef.current as any).webkitEnterFullscreen(); video.webkitEnterFullscreen();
} }
setFullscreenMode('native'); setFullscreenMode('native');
@@ -128,9 +223,9 @@ export function useFullscreenControls({
await lockLandscape(); await lockLandscape();
} catch (error) { } catch (error) {
console.warn('Fullscreen request failed, trying fallback:', error); console.warn('Fullscreen request failed, trying fallback:', error);
if (videoRef.current && (videoRef.current as any).webkitEnterFullscreen) { if (video?.webkitEnterFullscreen) {
try { try {
(videoRef.current as any).webkitEnterFullscreen(); video.webkitEnterFullscreen();
setFullscreenMode('native'); setFullscreenMode('native');
setIsFullscreen(true); setIsFullscreen(true);
} catch (fallbackError) { } catch (fallbackError) {
@@ -148,6 +243,65 @@ export function useFullscreenControls({
videoRef, 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 () => { const toggleWindowFullscreen = useCallback(async () => {
if (fullscreenMode === 'window') { if (fullscreenMode === 'window') {
exitWindowFullscreen(); exitWindowFullscreen();
@@ -251,27 +405,56 @@ export function useFullscreenControls({
return () => window.removeEventListener('keydown', handleEsc); return () => window.removeEventListener('keydown', handleEsc);
}, [exitWindowFullscreen, fullscreenMode]); }, [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 () => { const togglePictureInPicture = useCallback(async () => {
if (!videoRef.current || !isPiPSupported) return; if (!videoRef.current || !isPiPSupported) return;
const video = videoRef.current as any; const video = videoRef.current as PiPCapableVideoElement;
const fullscreenDocument = getFullscreenDocument();
try { try {
if (document.pictureInPictureElement) { if (fullscreenDocument.pictureInPictureElement && fullscreenDocument.exitPictureInPicture) {
await document.exitPictureInPicture(); await fullscreenDocument.exitPictureInPicture();
} else if (video.webkitPresentationMode === 'picture-in-picture') { } else if (video.webkitPresentationMode === 'picture-in-picture') {
video.webkitSetPresentationMode('inline'); video.webkitSetPresentationMode?.('inline');
} else if (video.requestPictureInPicture) { } else if (video.requestPictureInPicture && fullscreenDocument.pictureInPictureEnabled) {
await video.requestPictureInPicture(); 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'); video.webkitSetPresentationMode('picture-in-picture');
} }
} catch (error) { } catch (error) {
if (await requestAndroidPictureInPicture()) {
return;
}
console.error('Failed to toggle Picture-in-Picture:', error); console.error('Failed to toggle Picture-in-Picture:', error);
} }
}, [videoRef, isPiPSupported]); }, [isPiPSupported, requestAndroidPictureInPicture, videoRef]);
const showAirPlayMenu = useCallback(() => { const showAirPlayMenu = useCallback(() => {
if (!videoRef.current || !isAirPlaySupported) return; if (!videoRef.current || !isAirPlaySupported) return;
const video = videoRef.current as any; const video = videoRef.current as PiPCapableVideoElement;
if (video.webkitShowPlaybackTargetPicker) { if (video.webkitShowPlaybackTargetPicker) {
video.webkitShowPlaybackTargetPicker(); video.webkitShowPlaybackTargetPicker();
} }
+10 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import Hls from 'hls.js'; import Hls from 'hls.js';
import { usePlayerSettings } from './usePlayerSettings'; import { usePlayerSettings } from './usePlayerSettings';
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils'; import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
interface UseHlsPlayerProps { interface UseHlsPlayerProps {
videoRef: React.RefObject<HTMLVideoElement | null>; videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -22,6 +23,7 @@ export function useHlsPlayer({
}: UseHlsPlayerProps) { }: UseHlsPlayerProps) {
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium); const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
const { mediaProxyEnabled } = useRuntimeFeatures();
const isAdFilterEnabled = adFilterMode !== 'off'; const isAdFilterEnabled = adFilterMode !== 'off';
useEffect(() => { useEffect(() => {
@@ -225,6 +227,9 @@ export function useHlsPlayer({
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text(); return await res.text();
} catch (e) { } catch (e) {
if (!mediaProxyEnabled) {
throw e;
}
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e); console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`; const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
const res = await fetch(proxiedUrl); const res = await fetch(proxiedUrl);
@@ -397,6 +402,10 @@ export function useHlsPlayer({
const handleError = () => { const handleError = () => {
if (directFailed) return; if (directFailed) return;
directFailed = true; directFailed = true;
if (!mediaProxyEnabled) {
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
return;
}
// Try proxied URL as final attempt // Try proxied URL as final attempt
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`; const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
video.src = proxiedUrl; video.src = proxiedUrl;
@@ -415,5 +424,5 @@ export function useHlsPlayer({
} }
extraBlobs.forEach(url => URL.revokeObjectURL(url)); extraBlobs.forEach(url => URL.revokeObjectURL(url));
}; };
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]); }, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
} }
+33 -8
View File
@@ -10,6 +10,7 @@ import {
premiumModeSettingsStore, premiumModeSettingsStore,
type ModeSettings, type ModeSettings,
} from '@/lib/store/premium-mode-settings'; } from '@/lib/store/premium-mode-settings';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
interface PlayerSettingsSnapshot { interface PlayerSettingsSnapshot {
autoNextEpisode: boolean; autoNextEpisode: boolean;
@@ -30,7 +31,7 @@ interface PlayerSettingsSnapshot {
danmakuDisplayArea: number; danmakuDisplayArea: number;
} }
function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot { function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolean): PlayerSettingsSnapshot {
const globalSettings = settingsStore.getSettings(); const globalSettings = settingsStore.getSettings();
const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings; const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings;
@@ -45,7 +46,7 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
adFilterMode: modeSettings.adFilterMode, adFilterMode: modeSettings.adFilterMode,
adKeywords: globalSettings.adKeywords, adKeywords: globalSettings.adKeywords,
fullscreenType: modeSettings.fullscreenType, fullscreenType: modeSettings.fullscreenType,
proxyMode: modeSettings.proxyMode, proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
danmakuEnabled: modeSettings.danmakuEnabled, danmakuEnabled: modeSettings.danmakuEnabled,
danmakuApiUrl: modeSettings.danmakuApiUrl, danmakuApiUrl: modeSettings.danmakuApiUrl,
danmakuOpacity: modeSettings.danmakuOpacity, 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 * Hook to access and update player settings from the settings store
* Provides reactive updates when settings change * Provides reactive updates when settings change
*/ */
export function usePlayerSettings(isPremium: boolean = false) { 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(() => { useEffect(() => {
const syncSettings = () => { const syncSettings = () => {
setSettings(getPlayerSettingsSnapshot(isPremium)); const next = getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled);
setSettings((prev) => (playerSettingsEqual(prev, next) ? prev : next));
}; };
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
@@ -77,7 +102,7 @@ export function usePlayerSettings(isPremium: boolean = false) {
unsubscribeModeStore(); unsubscribeModeStore();
unsubscribeGlobalStore?.(); unsubscribeGlobalStore?.();
}; };
}, [isPremium]); }, [isPremium, mediaProxyEnabled]);
const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => { const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => {
if (isPremium) { if (isPremium) {
@@ -145,8 +170,8 @@ export function usePlayerSettings(isPremium: boolean = false) {
}, [updateModeSettings]); }, [updateModeSettings]);
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => { const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
updateModeSettings({ proxyMode: value }); updateModeSettings({ proxyMode: mediaProxyEnabled ? value : 'none' });
}, [updateModeSettings]); }, [mediaProxyEnabled, updateModeSettings]);
const setDanmakuEnabled = useCallback((value: boolean) => { const setDanmakuEnabled = useCallback((value: boolean) => {
updateModeSettings({ danmakuEnabled: value }); updateModeSettings({ danmakuEnabled: value });
+5
View File
@@ -16,6 +16,11 @@
--kvideo-web-scale: 1; --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 { .kvideo-container.is-web-fullscreen .kvideo-web-fullscreen-stage {
position: relative !important; 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; 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;
+19 -14
View File
@@ -78,16 +78,26 @@ export const VideoCard = memo<VideoCardProps>(({
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={(e) => { onError={(e) => {
const target = e.currentTarget as HTMLImageElement; 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"> <Image
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" /> src="/placeholder-poster.svg"
</div> 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"> <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" /> <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> <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="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"> <div className="flex items-center gap-1 min-w-0">
{video.sourceName && ( {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} {video.sourceName}
</Badge> </Badge>
)} )}
{video.type_name && (
<Badge variant="secondary" className="flex-shrink-0 max-w-[40%] truncate text-[10px]">
{video.type_name}
</Badge>
)}
</div> </div>
{displayLatency !== undefined && (
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
)}
</div> </div>
{/* Favorite Button - Top Right */} {/* Favorite Button - Top Right */}
@@ -179,6 +181,9 @@ export const VideoCard = memo<VideoCardProps>(({
... ...
</span> </span>
) : null} ) : null}
{displayLatency !== undefined && (
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
)}
</div> </div>
{video.vod_lang && ( {video.vod_lang && (
<p className="text-xs text-[var(--text-color-secondary)] mt-1"> <p className="text-xs text-[var(--text-color-secondary)] mt-1">
File diff suppressed because it is too large Load Diff
+246
View File
@@ -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>
);
}
+89 -32
View File
@@ -6,6 +6,8 @@
*/ */
import { Icons } from '@/components/ui/Icon'; import { Icons } from '@/components/ui/Icon';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import { Switch } from '@/components/ui/Switch';
import { import {
type ProxyMode, type ProxyMode,
DEFAULT_SEEK_STEP_SECONDS, DEFAULT_SEEK_STEP_SECONDS,
@@ -20,6 +22,8 @@ interface PlayerSettingsProps {
onProxyModeChange: (mode: ProxyMode) => void; onProxyModeChange: (mode: ProxyMode) => void;
seekStepSeconds: number; seekStepSeconds: number;
onSeekStepSecondsChange: (value: number) => void; onSeekStepSecondsChange: (value: number) => void;
videoTogetherEnabled?: boolean;
onVideoTogetherEnabledChange?: (enabled: boolean) => void;
danmakuApiUrl: string; danmakuApiUrl: string;
onDanmakuApiUrlChange: (url: string) => void; onDanmakuApiUrlChange: (url: string) => void;
danmakuOpacity: number; danmakuOpacity: number;
@@ -47,6 +51,8 @@ export function PlayerSettings({
onProxyModeChange, onProxyModeChange,
seekStepSeconds, seekStepSeconds,
onSeekStepSecondsChange, onSeekStepSecondsChange,
videoTogetherEnabled,
onVideoTogetherEnabledChange,
danmakuApiUrl, danmakuApiUrl,
onDanmakuApiUrlChange, onDanmakuApiUrlChange,
danmakuOpacity, danmakuOpacity,
@@ -57,6 +63,9 @@ export function PlayerSettings({
onDanmakuDisplayAreaChange, onDanmakuDisplayAreaChange,
showDanmakuApi = true, showDanmakuApi = true,
}: PlayerSettingsProps) { }: PlayerSettingsProps) {
const { mediaProxyEnabled, restrictionSummary } = useRuntimeFeatures();
const effectiveProxyMode = mediaProxyEnabled ? proxyMode : 'none';
return ( return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6"> <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> <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 className="text-sm text-[var(--text-color-secondary)] mb-4">
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> {mediaProxyEnabled ? (
<button <div className="space-y-3">
onClick={() => onProxyModeChange('retry')} <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry' <button
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]' onClick={() => onProxyModeChange('retry')}
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]' 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> <div className="font-semibold"> ()</div>
<button <div className="text-sm opacity-80 mt-1"></div>
onClick={() => onProxyModeChange('none')} </button>
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none' <button
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]' onClick={() => onProxyModeChange('none')}
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]' 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> <div className="font-semibold"></div>
<button <div className="text-sm opacity-80 mt-1">使</div>
onClick={() => onProxyModeChange('always')} </button>
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always' <button
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]' onClick={() => onProxyModeChange('always')}
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]' 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 className="font-semibold"></div>
</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=&lt;encoded-video-url&gt;
</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>
<div className="border-t border-[var(--glass-border)]" /> <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 */} {/* Danmaku Settings */}
<div> <div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2"> <h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
+21
View File
@@ -69,6 +69,27 @@ export const MediaIcons = {
</svg> </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) => ( 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}> <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" /> <polygon points="5 4 15 12 5 20 5 4" />
+8
View File
@@ -5,12 +5,20 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
{
files: ["app/api/**/*.ts", "lib/server/**/*.ts"],
rules: {
"react/display-name": "off",
"react/prop-types": "off",
},
},
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
".vercel/**",
"next-env.d.ts", "next-env.d.ts",
]), ]),
]); ]);
+10 -5
View File
@@ -24,12 +24,12 @@ export async function fetchWithTimeout(
// If an external signal is provided, propagate its abort // If an external signal is provided, propagate its abort
const externalSignal = options.signal; const externalSignal = options.signal;
let onAbort: (() => void) | undefined;
if (externalSignal) { if (externalSignal) {
if (externalSignal.aborted) { if (externalSignal.aborted) {
clearTimeout(timeoutId);
controller.abort(); controller.abort();
} else { } else {
const onAbort = () => controller.abort(); onAbort = () => controller.abort();
externalSignal.addEventListener('abort', onAbort, { once: true }); externalSignal.addEventListener('abort', onAbort, { once: true });
} }
} }
@@ -39,11 +39,12 @@ export async function fetchWithTimeout(
...options, ...options,
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeoutId);
return response; return response;
} catch (error) { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
throw error; if (externalSignal && onAbort) {
externalSignal.removeEventListener('abort', onAbort);
}
} }
} }
@@ -62,6 +63,10 @@ export async function withRetry<T>(
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
if (lastError?.name === 'AbortError') {
throw lastError;
}
if (i < retries) { if (i < retries) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1))); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
} }
+69
View File
@@ -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);
}
+78
View File
@@ -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';
}
+51
View File
@@ -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,
};
}
+94
View File
@@ -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);
}
+19
View File
@@ -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);
}
+72
View File
@@ -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])];
}
+11
View File
@@ -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;
}
+1 -1
View File
@@ -18,4 +18,4 @@ export const siteConfig: SiteConfig = {
title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台", title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台",
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台", description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台",
name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo", name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo",
}; };
+10 -9
View File
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store'; import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store';
import { useFavoritesStore, usePremiumFavoritesStore } from '@/lib/store/favorites-store'; import { useFavoritesStore, usePremiumFavoritesStore } from '@/lib/store/favorites-store';
import { keepRenderableFavorites, keepRenderableHistory } from '@/lib/utils/sync-records';
import { getProfileId } from '@/lib/store/auth-store'; import { getProfileId } from '@/lib/store/auth-store';
export function useCloudSync(isPremium = false) { export function useCloudSync(isPremium = false) {
@@ -15,17 +16,18 @@ export function useCloudSync(isPremium = false) {
setIsSyncing(true); setIsSyncing(true);
try { try {
const response = await fetch('/api/user/sync', { const response = await fetch('/api/user/sync');
headers: { 'x-profile-id': profileId }
});
const result = await response.json(); const result = await response.json();
if (result.success && result.data) { if (result.success && result.data) {
if (result.data.history?.length > 0) { const history = keepRenderableHistory(result.data.history);
historyStore.getState().importHistory(result.data.history); const favorites = keepRenderableFavorites(result.data.favorites);
if (history.length > 0) {
historyStore.getState().importHistory(history);
} }
if (result.data.favorites?.length > 0) { if (favorites.length > 0) {
favoritesStore.getState().importFavorites(result.data.favorites); favoritesStore.getState().importFavorites(favorites);
} }
} }
} catch (error) { } catch (error) {
@@ -46,8 +48,7 @@ export function useCloudSync(isPremium = false) {
await fetch('/api/user/sync', { await fetch('/api/user/sync', {
method: 'POST', method: 'POST',
headers: { headers: {
'x-profile-id': profileId,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
+10 -15
View File
@@ -13,13 +13,8 @@ export function useConfigSync() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasPulled = useRef(false); const hasPulled = useRef(false);
const getHeaders = useCallback(() => { const hasSession = useCallback(() => {
const profileId = getProfileId(); return !!getProfileId();
if (!profileId) return null;
return {
'x-profile-id': profileId,
'Content-Type': 'application/json',
};
}, []); }, []);
// Pull config from server on mount (once) // Pull config from server on mount (once)
@@ -28,11 +23,10 @@ export function useConfigSync() {
hasPulled.current = true; hasPulled.current = true;
const pull = async () => { const pull = async () => {
const headers = getHeaders(); if (!hasSession()) return;
if (!headers) return;
try { try {
const res = await fetch('/api/user/config', { headers }); const res = await fetch('/api/user/config');
const result = await res.json(); const result = await res.json();
if (result.success && result.data) { if (result.success && result.data) {
@@ -83,7 +77,7 @@ export function useConfigSync() {
}; };
pull(); pull();
}, [getHeaders]); }, [hasSession]);
// Push config to server on settings change (debounced) // Push config to server on settings change (debounced)
useEffect(() => { useEffect(() => {
@@ -91,14 +85,15 @@ export function useConfigSync() {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
const headers = getHeaders(); if (!hasSession()) return;
if (!headers) return;
try { try {
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
await fetch('/api/user/config', { await fetch('/api/user/config', {
method: 'POST', method: 'POST',
headers, headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
sources: settings.sources, sources: settings.sources,
premiumSources: settings.premiumSources, premiumSources: settings.premiumSources,
@@ -130,5 +125,5 @@ export function useConfigSync() {
unsubscribe(); unsubscribe();
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
}; };
}, [getHeaders]); }, [hasSession]);
} }
+79 -40
View File
@@ -2,18 +2,23 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { profiledKey } from '@/lib/utils/profile-storage'; 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 StoredFloatingPosition extends FloatingButtonRatios {
/** Version 2 ratios are measured within the draggable area, not the viewport. */
interface FloatingButtonPosition { version?: 2;
x: number;
y: number;
} }
interface StoredFloatingPosition { const CURRENT_STORAGE_VERSION = 2;
xRatio: number;
yRatio: number;
}
interface UseFloatingButtonPositionOptions { interface UseFloatingButtonPositionOptions {
storageKey: string; storageKey: string;
@@ -45,10 +50,6 @@ const INITIAL_DRAG_STATE: DragState = {
offsetY: 0, offsetY: 0,
}; };
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function useFloatingButtonPosition({ export function useFloatingButtonPosition({
storageKey, storageKey,
defaultAnchor, defaultAnchor,
@@ -59,32 +60,44 @@ export function useFloatingButtonPosition({
const [position, setPosition] = useState<FloatingButtonPosition | null>(null); const [position, setPosition] = useState<FloatingButtonPosition | null>(null);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE); const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const positionRef = useRef<FloatingButtonPosition | null>(null); const positionRef = useRef<FloatingButtonPosition | null>(null);
const customRatiosRef = useRef<FloatingButtonRatios | null>(null);
const pointerUpHandlerRef = useRef<(event: PointerEvent) => void>(() => undefined);
const suppressClickRef = useRef(false); const suppressClickRef = useRef(false);
const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({ const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({
x: clamp(x, margin, Math.max(margin, width - buttonSize - margin)), ...clampFloatingButtonPosition(
y: clamp(y, margin, Math.max(margin, height - buttonSize - margin)), { x, y },
{ width, height },
buttonSize,
margin,
),
}), [buttonSize, margin]); }), [buttonSize, margin]);
const getDefaultPosition = useCallback((width: number, height: number) => { const getDefaultPosition = useCallback((width: number, height: number) => {
const x = defaultAnchor === 'left' return getDefaultFloatingButtonPosition(
? margin { width, height },
: Math.max(margin, width - buttonSize - margin); defaultAnchor,
const centeredY = height * defaultYRatio - buttonSize / 2; defaultYRatio,
buttonSize,
return clampPosition(x, centeredY, width, height); margin,
}, [buttonSize, clampPosition, defaultAnchor, defaultYRatio, margin]); );
}, [buttonSize, defaultAnchor, defaultYRatio, margin]);
const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => { const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const viewport: FloatingButtonViewport = {
width: window.innerWidth,
height: window.innerHeight,
};
const ratios = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin);
const payload: StoredFloatingPosition = { const payload: StoredFloatingPosition = {
xRatio: nextPosition.x / window.innerWidth, version: CURRENT_STORAGE_VERSION,
yRatio: nextPosition.y / window.innerHeight, ...ratios,
}; };
localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload)); localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload));
}, [storageKey]); }, [buttonSize, margin, storageKey]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -93,6 +106,7 @@ export function useFloatingButtonPosition({
const width = window.innerWidth; const width = window.innerWidth;
const height = window.innerHeight; const height = window.innerHeight;
const fallbackPosition = getDefaultPosition(width, height); const fallbackPosition = getDefaultPosition(width, height);
customRatiosRef.current = null;
try { try {
const raw = localStorage.getItem(profiledKey(storageKey)); const raw = localStorage.getItem(profiledKey(storageKey));
@@ -102,20 +116,30 @@ export function useFloatingButtonPosition({
return; return;
} }
const parsed = JSON.parse(raw) as Partial<StoredFloatingPosition>; const parsed = JSON.parse(raw) as Partial<StoredFloatingPosition> & { version?: number };
if (typeof parsed.xRatio !== 'number' || typeof parsed.yRatio !== '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; positionRef.current = fallbackPosition;
setPosition(fallbackPosition); setPosition(fallbackPosition);
return; return;
} }
const nextPosition = clampPosition( const viewport = { width, height };
parsed.xRatio * width, const nextPosition = parsed.version === CURRENT_STORAGE_VERSION
parsed.yRatio * height, ? getPositionFromFloatingButtonRatios(
width, { xRatio: parsed.xRatio, yRatio: parsed.yRatio },
height viewport,
); buttonSize,
margin,
)
: clampPosition(parsed.xRatio * width, parsed.yRatio * height, width, height);
customRatiosRef.current = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin);
positionRef.current = nextPosition; positionRef.current = nextPosition;
setPosition(nextPosition); setPosition(nextPosition);
} catch { } catch {
@@ -129,9 +153,14 @@ export function useFloatingButtonPosition({
const handleResize = () => { const handleResize = () => {
const width = window.innerWidth; const width = window.innerWidth;
const height = window.innerHeight; const height = window.innerHeight;
const fallbackPosition = getDefaultPosition(width, height); const nextPosition = customRatiosRef.current
const basePosition = positionRef.current || fallbackPosition; ? getPositionFromFloatingButtonRatios(
const nextPosition = clampPosition(basePosition.x, basePosition.y, width, height); customRatiosRef.current,
{ width, height },
buttonSize,
margin,
)
: getDefaultPosition(width, height);
positionRef.current = nextPosition; positionRef.current = nextPosition;
setPosition(nextPosition); setPosition(nextPosition);
@@ -139,7 +168,7 @@ export function useFloatingButtonPosition({
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, [clampPosition, getDefaultPosition, storageKey]); }, [buttonSize, clampPosition, getDefaultPosition, margin, storageKey]);
const finishDrag = useCallback(() => { const finishDrag = useCallback(() => {
const dragState = dragStateRef.current; const dragState = dragStateRef.current;
@@ -180,9 +209,15 @@ export function useFloatingButtonPosition({
window.innerHeight window.innerHeight
); );
customRatiosRef.current = getFloatingButtonRatios(
nextPosition,
{ width: window.innerWidth, height: window.innerHeight },
buttonSize,
margin,
);
positionRef.current = nextPosition; positionRef.current = nextPosition;
setPosition(nextPosition); setPosition(nextPosition);
}, [clampPosition]); }, [buttonSize, clampPosition, margin]);
const handlePointerUp = useCallback((event: PointerEvent) => { const handlePointerUp = useCallback((event: PointerEvent) => {
const dragState = dragStateRef.current; const dragState = dragStateRef.current;
@@ -193,10 +228,14 @@ export function useFloatingButtonPosition({
finishDrag(); finishDrag();
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', pointerUpHandlerRef.current);
window.removeEventListener('pointercancel', handlePointerUp); window.removeEventListener('pointercancel', pointerUpHandlerRef.current);
}, [finishDrag, handlePointerMove]); }, [finishDrag, handlePointerMove]);
useEffect(() => {
pointerUpHandlerRef.current = handlePointerUp;
}, [handlePointerUp]);
useEffect(() => { useEffect(() => {
return () => { return () => {
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
+62 -30
View File
@@ -5,6 +5,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { settingsStore } from '@/lib/store/settings-store'; import { settingsStore } from '@/lib/store/settings-store';
import { probeLatencyTargets } from '@/lib/utils/latency';
interface LatencyState { interface LatencyState {
[sourceId: string]: number; [sourceId: string]: number;
@@ -23,17 +24,18 @@ export function useLatencyPing({
}: UseLatencyPingOptions) { }: UseLatencyPingOptions) {
const [latencies, setLatencies] = useState<LatencyState>({}); const [latencies, setLatencies] = useState<LatencyState>({});
const [isLoading, setIsLoading] = useState(false); 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); const mountedRef = useRef(true);
// Check if real-time latency is enabled in settings // Check if real-time latency is enabled in settings
const [realtimeEnabled, setRealtimeEnabled] = useState(false); const [realtimeEnabled, setRealtimeEnabled] = useState(false);
// Stabilize sourceUrls to prevent unnecessary effect re-runs if parent passes new array // Stabilize sourceUrls to prevent unnecessary effect re-runs if parent passes new array
const stableSourceUrls = useMemo(() => sourceUrls, [ const stableSourceUrls = useMemo(
// Create a unique key for the sources array () => sourceUrls.map((source) => ({ ...source })),
sourceUrls.map(s => `${s.id}|${s.baseUrl}`).join(',') [sourceUrls],
]); );
useEffect(() => { useEffect(() => {
const settings = settingsStore.getSettings(); 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 { try {
const response = await fetch('/api/ping', { const response = await fetch('/api/ping', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: baseUrl }), body: JSON.stringify({ url: baseUrl }),
signal: controller.signal,
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
return data.latency || null; return data.success && typeof data.latency === 'number'
? data.latency
: null;
} }
return null; return null;
} catch { } catch {
return null; return null;
} finally {
clearTimeout(timeoutId);
} }
}, []); }, []);
const pingAllSources = useCallback(async () => { 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); setIsLoading(true);
const results = await Promise.all( try {
stableSourceUrls.map(async ({ id, baseUrl }) => { const results = await probeLatencyTargets(
const latency = await pingSource(id, baseUrl); stableSourceUrls,
return { id, latency }; ({ baseUrl }) => pingSource(baseUrl),
}) );
);
if (mountedRef.current) { if (mountedRef.current) {
setLatencies(prev => { setLatencies(prev => {
const newState = { ...prev }; const newState = { ...prev };
results.forEach(({ id, latency }) => { results.forEach(({ id, latency }) => {
if (latency !== null) { if (latency !== null) {
newState[id] = latency; newState[id] = latency;
} }
});
return newState;
}); });
return newState; }
}); } finally {
setIsLoading(false); roundInFlightRef.current = false;
if (mountedRef.current) {
setIsLoading(false);
}
} }
}, [stableSourceUrls, pingSource]); }, [stableSourceUrls, pingSource]);
@@ -101,17 +120,30 @@ export function useLatencyPing({
const shouldPoll = enabled && realtimeEnabled && stableSourceUrls.length > 0; const shouldPoll = enabled && realtimeEnabled && stableSourceUrls.length > 0;
if (shouldPoll) { if (shouldPoll) {
// Initial ping let cancelled = false;
pingAllSources(); const poll = async () => {
await pingAllSources();
if (!cancelled && mountedRef.current) {
intervalRef.current = setTimeout(poll, intervalMs);
}
};
// Set up interval void poll();
intervalRef.current = setInterval(pingAllSources, intervalMs);
return () => {
cancelled = true;
mountedRef.current = false;
if (intervalRef.current) {
clearTimeout(intervalRef.current);
intervalRef.current = null;
}
};
} }
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearTimeout(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
} }
}; };
@@ -120,7 +152,7 @@ export function useLatencyPing({
const refreshLatency = useCallback((sourceId: string) => { const refreshLatency = useCallback((sourceId: string) => {
const source = stableSourceUrls.find(s => s.id === sourceId); const source = stableSourceUrls.find(s => s.id === sourceId);
if (source) { if (source) {
pingSource(sourceId, source.baseUrl).then(latency => { pingSource(source.baseUrl).then(latency => {
if (latency !== null && mountedRef.current) { if (latency !== null && mountedRef.current) {
setLatencies(prev => ({ ...prev, [sourceId]: latency })); setLatencies(prev => ({ ...prev, [sourceId]: latency }));
} }
+87 -49
View File
@@ -1,41 +1,54 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; 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 { export type ResolutionInfo = ResolutionCacheEntry;
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 */ }
}
interface VideoToProbe { interface VideoToProbe {
id: string | number; id: string | number;
source: string; 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. * Hook that probes actual video resolutions via m3u8 manifests.
* Returns a map of "source:id" -> ResolutionInfo. * Returns a map of "source:id" -> ResolutionInfo.
* Results are cached in sessionStorage.
*/ */
export function useResolutionProbe(videos: VideoToProbe[]): { export function useResolutionProbe(videos: VideoToProbe[]): {
resolutions: Record<string, ResolutionInfo | null>; resolutions: Record<string, ResolutionInfo | null>;
@@ -44,35 +57,41 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({}); const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
const [isProbing, setIsProbing] = useState(false); const [isProbing, setIsProbing] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
// Track which videos we've already started probing to avoid duplicates const completedKeysRef = useRef<Set<string>>(new Set());
const probedKeysRef = useRef<Set<string>>(new Set()); const inFlightKeysRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!videos || videos.length === 0) return; 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 cached: Record<string, ResolutionInfo | null> = {};
const needProbe: VideoToProbe[] = []; const needProbe: VideoToProbe[] = [];
const batchRequestKeys: string[] = [];
for (const v of videos) { for (const video of videos) {
const key = `${v.source}:${v.id}`; const resultKey = `${video.source}:${video.id}`;
const cachedInfo = getCached(v.source, v.id); const requestKey = `${video.source}:${video.id}:${video.episodeIndex ?? 0}`;
if (cachedInfo) { const cachedInfo = getCachedResolution(video.source, video.id);
cached[key] = cachedInfo;
} else if (!probedKeysRef.current.has(key)) { if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) {
needProbe.push(v); cached[resultKey] = cachedInfo;
probedKeysRef.current.add(key); } 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) { if (Object.keys(cached).length > 0) {
setResolutions(prev => ({ ...prev, ...cached })); setResolutions((previous) => ({ ...previous, ...cached }));
} }
if (needProbe.length === 0) return; if (needProbe.length === 0) return;
// Abort previous request
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
@@ -81,10 +100,11 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
(async () => { (async () => {
try { try {
const sourceConfigs = getSourceConfigsForProbe(needProbe);
const response = await fetch('/api/probe-resolution', { const response = await fetch('/api/probe-resolution', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videos: needProbe }), body: JSON.stringify({ videos: needProbe, sourceConfigs }),
signal: controller.signal, signal: controller.signal,
}); });
@@ -107,29 +127,47 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data: ')) continue; if (!line.startsWith('data: ')) continue;
try { try {
const data = JSON.parse(line.slice(6)); const data = JSON.parse(line.slice(6)) as ResolutionProbeEvent;
if (data.done) continue; 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) { if (data.resolution) {
setCache(data.source, data.id, data.resolution); const resolution: ResolutionInfo = {
setResolutions(prev => ({ ...prev, [key]: data.resolution })); ...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 { } else {
setResolutions(prev => ({ ...prev, [key]: null })); setResolutions((previous) => ({ ...previous, [resultKey]: null }));
} }
} catch { /* ignore */ } } catch {
// Ignore malformed SSE chunks and continue reading.
}
} }
} }
} catch (e: any) { } catch (error: unknown) {
if (e?.name !== 'AbortError') { if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.warn('[ResolutionProbe] Failed:', e); console.warn('[ResolutionProbe] Failed:', error);
} }
} finally { } finally {
for (const requestKey of batchRequestKeys) {
inFlightKeys.delete(requestKey);
}
setIsProbing(false); setIsProbing(false);
} }
})(); })();
return () => { return () => {
controller.abort(); controller.abort();
for (const requestKey of batchRequestKeys) {
inFlightKeys.delete(requestKey);
}
}; };
}, [videos]); }, [videos]);
+7 -3
View File
@@ -1,14 +1,13 @@
import { useRef, useCallback } from 'react'; import { useRef, useCallback } from 'react';
import { SOURCE_IDS } from '@/lib/utils/source-names';
import { sortVideos } from '@/lib/utils/sort'; import { sortVideos } from '@/lib/utils/sort';
import { binaryInsertVideos } from '@/lib/utils/sorted-insert'; import { binaryInsertVideos } from '@/lib/utils/sorted-insert';
import { processSearchStream } from '@/lib/utils/search-stream'; import { processSearchStream } from '@/lib/utils/search-stream';
import type { SortOption } from '@/lib/store/settings-store'; import type { SortOption } from '@/lib/store/settings-store';
import { settingsStore } from '@/lib/store/settings-store'; import { settingsStore } from '@/lib/store/settings-store';
import type { Video } from '@/lib/types';
import { useSearchState } from './useSearchState'; import { useSearchState } from './useSearchState';
type SearchState = ReturnType<typeof useSearchState>; type SearchState = ReturnType<typeof useSearchState>;
type SearchSourceConfig = { id: string; baseUrl?: string };
interface UseSearchActionProps { interface UseSearchActionProps {
state: SearchState; state: SearchState;
@@ -77,7 +76,10 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (!reader) throw new Error('No response stream'); 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({ await processSearchStream({
reader, reader,
@@ -95,6 +97,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
sourcesMap.set(sourceId, { sourcesMap.set(sourceId, {
count: newVideos.length, count: newVideos.length,
name: newVideos[0]?.sourceName || sourceId, name: newVideos[0]?.sourceName || sourceId,
baseUrl: sourceConfigs.get(sourceId)?.baseUrl,
}); });
} }
}, },
@@ -113,6 +116,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
id: id, id: id,
name: info.name, name: info.name,
count: info.count, count: info.count,
...(info.baseUrl ? { baseUrl: info.baseUrl } : {}),
})); }));
setAvailableSources(sources); setAvailableSources(sources);
+15
View File
@@ -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;
}
+96
View File
@@ -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));
}
+50
View File
@@ -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;
}
+178
View File
@@ -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);
}
+46
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export function authenticationRequiredResponse(): NextResponse {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
+339
View File
@@ -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