feat: 接入torbox视频转码

This commit is contained in:
2026-10-05 22:28:37 -07:00
parent b289a4c3b8
commit 8711a42dde
18 changed files with 618 additions and 155 deletions
+10 -6
View File
@@ -4,7 +4,7 @@
TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页资源并存入云端,再以直链或串流取回。官网自称 _Private Infrastructure Provider_,视觉为墨黑底、翠绿强调色、点阵标题字体、6px 圆角。 TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页资源并存入云端,再以直链或串流取回。官网自称 _Private Infrastructure Provider_,视觉为墨黑底、翠绿强调色、点阵标题字体、6px 圆角。
本项目是自托管的 TorBox 面板,核心任务是**浏览资源并一键交给 PotPlayer 串流**。风格定为 **「私有云控制台」**: 本项目是自托管的 TorBox 面板,核心任务是**浏览资源并直接播放**:视频默认在网页内播放,PotPlayer 作为扩展的打开方式。风格定为 **「私有云控制台」**:
- 墨黑底 + 翠绿**唯一**强调色,克制、不花哨 - 墨黑底 + 翠绿**唯一**强调色,克制、不花哨
- 点阵字标呼应 TorBox 品牌,等宽字体承载一切数据 - 点阵字标呼应 TorBox 品牌,等宽字体承载一切数据
@@ -100,10 +100,11 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 表单 | `FieldGroup` + `Field` + `FieldLabel` + `InputGroup` | | 表单 | `FieldGroup` + `Field` + `FieldLabel` + `InputGroup` |
| 2–5 个选项切换 | `ToggleGroup`(`variant="outline" size="sm" spacing={0}`) | | 2–5 个选项切换 | `ToggleGroup`(`variant="outline" size="sm" spacing={0}`) |
| 资源分组 | `ItemHeader`(`aria-expanded` 按钮,展开状态在 `library-view-store`);窄屏只显示来源色点、过期时间省略「后过期」,给标题留空间 | | 资源分组 | `ItemHeader`(`aria-expanded` 按钮,展开状态在 `library-view-store`);窄屏只显示来源色点、过期时间省略「后过期」,给标题留空间 |
| 文件卡片 | `Card size="sm"` + `CardContent` + `CardFooter` | | 文件卡片 | `Card size="sm"` + `CardContent` + `CardFooter`;底栏左侧一个主操作(视频「播放」、图片「预览」,其他文件没有),右侧图标操作(视频多一个 PotPlayer,所有文件都有下载、复制直链),名称放在 `Tooltip` 与 `aria-label`;卡片是 `@container/card`,窄于 186px(手机两列)时主操作只显示图标 |
| 标签 | `Badge`(来源:`variant="outline"` + `SourceDot`) | | 标签 | `Badge`(来源:`variant="outline"` + `SourceDot`) |
| 下拉操作 | `DropdownMenu` | | 下拉操作 | `DropdownMenu` |
| 图标按钮说明 | `Tooltip` | | 图标按钮说明 | `Tooltip` |
| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放,`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),标题下注明「TorBox 转码」;底栏提供下载与「PotPlayer 播放」;播放错误说明替换为中文并提示换用 PotPlayer |
| 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | | 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) | | 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) |
| 错误提示 | `Alert variant="destructive"` | | 错误提示 | `Alert variant="destructive"` |
@@ -114,8 +115,9 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
按钮层级: 按钮层级:
- `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「PotPlayer」「进入」。 - `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「播放」「进入」。
- `secondary`——次要但常用,如「预览」「下载」。 - `secondary`——次要但常用,如「预览」。
- 卡片上的下载、复制直链、PotPlayer 一律是 `ghost` + `icon-sm` 图标按钮,不作为主操作。
- `outline` / `ghost`——工具栏与 header 中的辅助操作、图标按钮。 - `outline` / `ghost`——工具栏与 header 中的辅助操作、图标按钮。
## 7. 状态 ## 7. 状态
@@ -124,7 +126,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| ------------ | ------------------------------------------------------------ | | ------------ | ------------------------------------------------------------ |
| 加载中 | `Skeleton` 复刻真实布局(分组标题 + 卡片网格) | | 加载中 | `Skeleton` 复刻真实布局(分组标题 + 卡片网格) |
| 刷新中 | 刷新按钮内 `Spinner` 并禁用,列表保持原内容;手动刷新带 `?fresh=1`,服务端以 `bypass_cache` 绕过 TorBox 列表缓存(torrent 默认 600 秒才更新),自动重新获取仍走缓存 | | 刷新中 | 刷新按钮内 `Spinner` 并禁用,列表保持原内容;手动刷新带 `?fresh=1`,服务端以 `bypass_cache` 绕过 TorBox 列表缓存(torrent 默认 600 秒才更新),自动重新获取仍走缓存 |
| 资源下载中 | 标题行显示 `Progress` + 百分比 + 状态名(`DOWNLOAD_STATE_LABELS`);卡片封面为沙漏占位,主操作禁用但可聚焦(`focusableWhenDisabled`),复制直链禁用;不进入 M3U 播放列表 | | 资源下载中 | 标题行显示 `Progress` + 百分比 + 状态名(`DOWNLOAD_STATE_LABELS`);卡片封面为沙漏占位,主操作(没有主操作时为下载图标)禁用但可聚焦(`focusableWhenDisabled`),其余图标操作禁用;不进入 M3U 播放列表 |
| 资源已失效 | 下载完成但文件不在服务器上:标题行 `destructive` 色「文件不可用」,卡片同上禁用 | | 资源已失效 | 下载完成但文件不在服务器上:标题行 `destructive` 色「文件不可用」,卡片同上禁用 |
| 资源快过期 | 距 `expires_at` 不足 3 天:标题行 `ClockAlert` +「N 天后过期」(`muted-foreground`);不足 24 小时或已过期转为 `destructive`;悬停显示具体时间。阈值见 `EXPIRING_WINDOW_MS / EXPIRING_URGENT_MS`;文件已不可用时不重复提示 | | 资源快过期 | 距 `expires_at` 不足 3 天:标题行 `ClockAlert` +「N 天后过期」(`muted-foreground`);不足 24 小时或已过期转为 `destructive`;悬停显示具体时间。阈值见 `EXPIRING_WINDOW_MS / EXPIRING_URGENT_MS`;文件已不可用时不重复提示 |
| 演示模式 | 未配置 `TORBOX_API_KEY` 时资源库返回 `server/mock-library.ts` 的模拟数据;header 字标旁 `Badge variant="outline"`「演示数据」(窄屏隐藏);图片文件返回生成的 SVG 占位图,可预览、缩放,其他文件不可播放;未配置 `ACCESS_TOKEN` 时登录框下方 `FieldDescription` 提示演示密钥 | | 演示模式 | 未配置 `TORBOX_API_KEY` 时资源库返回 `server/mock-library.ts` 的模拟数据;header 字标旁 `Badge variant="outline"`「演示数据」(窄屏隐藏);图片文件返回生成的 SVG 占位图,可预览、缩放,其他文件不可播放;未配置 `ACCESS_TOKEN` 时登录框下方 `FieldDescription` 提示演示密钥 |
@@ -142,6 +144,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| ---------- | ---------------- | | ---------- | ---------------- |
| 品牌 | `Box` | | 品牌 | `Box` |
| 播放 | `Play` | | 播放 | `Play` |
| PotPlayer | `MonitorPlay` |
| 复制直链 | `Copy` | | 复制直链 | `Copy` |
| 图片预览 | `Eye` | | 图片预览 | `Eye` |
| 下载 | `Download` | | 下载 | `Download` |
@@ -175,7 +178,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| `src/utils/` | `scramble`、`grid-nav`、`layout`、`date`、`sort` | 纯函数,无 React、无业务 | | `src/utils/` | `scramble`、`grid-nav`、`layout`、`date`、`sort` | 纯函数,无 React、无业务 |
| `src/lib/dot-field.ts` | 画布点阵场引擎 | 不依赖 React;渲染循环用 Motion `frame`,涟漪时序用 `animate` | | `src/lib/dot-field.ts` | 画布点阵场引擎 | 不依赖 React;渲染循环用 Motion `frame`,涟漪时序用 `animate` |
| `src/hooks/` | `use-follow-highlight`、`use-spotlight`、`use-grid-keyboard-nav`、`use-hotkey` | 通用交互,无业务语义;只产出 MotionValue / 几何 | | `src/hooks/` | `use-follow-highlight`、`use-spotlight`、`use-grid-keyboard-nav`、`use-hotkey` | 通用交互,无业务语义;只产出 MotionValue / 几何 |
| `src/components/` | `Reticle`、`DotField`、`ScrambleText`、`AnimatedNumber`、`TypeText`、`ImageLightbox` | 通用展示组件,外观在这里 | | `src/components/` | `Reticle`、`DotField`、`ScrambleText`、`AnimatedNumber`、`TypeText`、`ImageLightbox`、`VideoPlayer` | 通用展示组件,外观在这里 |
| 页面组件 | `library-list`、`file-card`、`login-stage` 等 | 只做接线:传选择器、参数与回调 | | 页面组件 | `library-list`、`file-card`、`login-stage` 等 | 只做接线:传选择器、参数与回调 |
- 容器级交互 hook 统一签名 `useXxx(containerRef, options)`,在容器上做事件委托,彼此独立;删掉一行调用即可拆掉一个交互。 - 容器级交互 hook 统一签名 `useXxx(containerRef, options)`,在容器上做事件委托,彼此独立;删掉一行调用即可拆掉一个交互。
@@ -197,6 +200,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 | | 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 | | 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` | | 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
| 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 | | 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` | | 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |
+1
View File
@@ -13,6 +13,7 @@
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"@tanstack/react-virtual": "^3.14.13", "@tanstack/react-virtual": "^3.14.13",
"@videojs/core": "10.0.1", "@videojs/core": "10.0.1",
"@videojs/hlsjs-video": "10.0.1",
"@videojs/react": "10.0.1", "@videojs/react": "10.0.1",
"axios": "^1.20.0", "axios": "^1.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+22 -2
View File
@@ -20,9 +20,12 @@ importers:
'@videojs/core': '@videojs/core':
specifier: 10.0.1 specifier: 10.0.1
version: 10.0.1(react@19.2.8) version: 10.0.1(react@19.2.8)
'@videojs/hlsjs-video':
specifier: 10.0.1
version: 10.0.1
'@videojs/react': '@videojs/react':
specifier: 10.0.1 specifier: 10.0.1
version: 10.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 10.0.1(@videojs/hlsjs-video@10.0.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
axios: axios:
specifier: ^1.20.0 specifier: ^1.20.0
version: 1.20.0 version: 1.20.0
@@ -946,6 +949,9 @@ packages:
'@videojs/core@10.0.1': '@videojs/core@10.0.1':
resolution: {integrity: sha512-N+f41wgFcmpe0QUhO0Q9sFHpMxKvr38ojkX5FlxAo3LKDAo2UrG2KIeklte+1VVae3oaJfP6J/KYtrRRObRxxw==} resolution: {integrity: sha512-N+f41wgFcmpe0QUhO0Q9sFHpMxKvr38ojkX5FlxAo3LKDAo2UrG2KIeklte+1VVae3oaJfP6J/KYtrRRObRxxw==}
'@videojs/hlsjs-video@10.0.1':
resolution: {integrity: sha512-7Om99Vxsh7JYAVf04QO4SKgkIE2jrZ8c3KS5BtdQRqZaFYM5uWU9vubw3vwerI5nsrosg1KXcuzS5TGEUW4ToQ==}
'@videojs/media@10.0.1': '@videojs/media@10.0.1':
resolution: {integrity: sha512-hMC2aS/8PovwJBi8y2qShC/amYfH6SBce7SZAM54qzRq8hl4FwWGFeUMMmXp8x4agRxLuCSqGqZg/EoGH2I5mQ==} resolution: {integrity: sha512-hMC2aS/8PovwJBi8y2qShC/amYfH6SBce7SZAM54qzRq8hl4FwWGFeUMMmXp8x4agRxLuCSqGqZg/EoGH2I5mQ==}
@@ -1917,6 +1923,9 @@ packages:
hermes-parser@0.25.1: hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
hls.js@1.6.7:
resolution: {integrity: sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==}
hono@4.13.8: hono@4.13.8:
resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==} resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
@@ -4216,6 +4225,13 @@ snapshots:
- '@videojs/element' - '@videojs/element'
- react - react
'@videojs/hlsjs-video@10.0.1':
dependencies:
'@videojs/media': 10.0.1
'@videojs/native-hls-video': 10.0.1
'@videojs/utils': 10.0.1
hls.js: 1.6.7
'@videojs/media@10.0.1': '@videojs/media@10.0.1':
dependencies: dependencies:
'@videojs/utils': 10.0.1 '@videojs/utils': 10.0.1
@@ -4225,7 +4241,7 @@ snapshots:
'@videojs/media': 10.0.1 '@videojs/media': 10.0.1
'@videojs/utils': 10.0.1 '@videojs/utils': 10.0.1
'@videojs/react@10.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': '@videojs/react@10.0.1(@videojs/hlsjs-video@10.0.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies: dependencies:
'@videojs/core': 10.0.1(react@19.2.8) '@videojs/core': 10.0.1(react@19.2.8)
'@videojs/media': 10.0.1 '@videojs/media': 10.0.1
@@ -4236,6 +4252,8 @@ snapshots:
react: 19.2.8 react: 19.2.8
react-compiler-runtime: 1.0.0(react@19.2.8) react-compiler-runtime: 1.0.0(react@19.2.8)
react-dom: 19.2.8(react@19.2.8) react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@videojs/hlsjs-video': 10.0.1
transitivePeerDependencies: transitivePeerDependencies:
- '@videojs/element' - '@videojs/element'
@@ -5338,6 +5356,8 @@ snapshots:
dependencies: dependencies:
hermes-estree: 0.25.1 hermes-estree: 0.25.1
hls.js@1.6.7: {}
hono@4.13.8: {} hono@4.13.8: {}
http-errors@2.0.1: http-errors@2.0.1:
+132 -102
View File
@@ -11,6 +11,7 @@ import {
FileIcon, FileIcon,
HourglassIcon, HourglassIcon,
ImageOffIcon, ImageOffIcon,
MonitorPlayIcon,
PlayIcon, PlayIcon,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
@@ -28,6 +29,7 @@ import { useCopy } from "@/hooks/use-copy";
import type { LibraryFileView } from "@/hooks/use-library"; import type { LibraryFileView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin"; import { useOrigin } from "@/hooks/use-origin";
import { useSpotlight } from "@/hooks/use-spotlight"; import { useSpotlight } from "@/hooks/use-spotlight";
import { cn } from "@/lib/utils";
import { formatBytes } from "@/utils/format"; import { formatBytes } from "@/utils/format";
import type { DownloadStatus } from "@/types/torbox"; import type { DownloadStatus } from "@/types/torbox";
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media"; import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
@@ -53,25 +55,26 @@ const PlaceholderCover = ({
); );
/** /**
* 视频封面:点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。 * 视频封面:点击在网页内播放。点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。
* 两层同格叠放、内边距放在内容层,保证点阵对齐。 * 两层同格叠放、内边距放在内容层,保证点阵对齐;按钮内只能放行内元素,层都用 span。
*/ */
const VideoCover = ({ ext, href }: { ext: string; href: string }) => { const VideoCover = ({ ext, onPlay }: { ext: string; onPlay: () => void }) => {
const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT); const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT);
return ( return (
<a <button
href={href} type="button"
title="用 PotPlayer 播放" title="播放"
className="group/cover grid aspect-video bg-muted bg-dot-grid *:col-start-1 *:row-start-1" className="group/cover grid aspect-video bg-muted bg-dot-grid *:col-start-1 *:row-start-1"
onClick={onPlay}
{...handlers} {...handlers}
> >
<motion.div <motion.span
aria-hidden="true" aria-hidden="true"
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.5px]" className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.5px]"
style={{ maskImage, opacity }} style={{ maskImage, opacity }}
/> />
<div className="flex flex-col justify-between p-2.5"> <span className="flex flex-col items-start justify-between p-2.5">
<Badge variant="outline"> <Badge variant="outline">
<ExtLabel ext={ext} /> <ExtLabel ext={ext} />
</Badge> </Badge>
@@ -79,31 +82,82 @@ const VideoCover = ({ ext, href }: { ext: string; href: string }) => {
<PlayIcon className="size-5 fill-current" aria-hidden="true" /> <PlayIcon className="size-5 fill-current" aria-hidden="true" />
</span> </span>
<span className="h-5" aria-hidden="true" /> <span className="h-5" aria-hidden="true" />
</div> </span>
</a> </button>
); );
}; };
/** 资源未就绪时,主操作按钮保持原样式但禁用(仍可被键盘聚焦,方向键导航不中断) */ /** 主操作:视频在网页内播放、图片预览;其他文件没有主操作,只有图标操作 */
const PENDING_ACTION: Record< const PRIMARY_ACTION: Partial<
MediaKind, Record<
{ icon: LucideIcon; label: string; variant: "default" | "secondary" } MediaKind,
{ icon: LucideIcon; label: string; variant: "default" | "secondary" }
>
> = { > = {
video: { icon: PlayIcon, label: "PotPlayer", variant: "default" }, video: { icon: PlayIcon, label: "播放", variant: "default" },
image: { icon: EyeIcon, label: "预览", variant: "secondary" }, image: { icon: EyeIcon, label: "预览", variant: "secondary" }
other: { icon: DownloadIcon, label: "下载", variant: "secondary" }
}; };
type CardIconActionProps = {
label: string;
icon: LucideIcon;
disabled: boolean;
/** 链接型操作(PotPlayer、下载);不可用时退化为禁用按钮 */
href?: string;
download?: string;
onClick?: () => void;
/** 没有主操作时,由它接收方向键导航的焦点 */
focusTarget?: boolean;
};
/** 卡片底栏的图标操作:只显示图标,名称放在 Tooltip 与 aria-label */
const CardIconAction = ({
label,
icon: Icon,
disabled,
href,
download,
onClick,
focusTarget = false
}: CardIconActionProps) => (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={label}
className={cn(focusTarget && "aria-disabled:opacity-50")}
disabled={disabled}
// 资源未就绪时仍可被键盘聚焦,方向键导航不中断
focusableWhenDisabled={focusTarget}
onClick={onClick}
{...(href && !disabled
? {
nativeButton: false,
render: <a href={href} download={download} />
}
: {})}
{...(focusTarget ? CARD_FOCUS_PROPS : {})}
/>
}
>
<Icon />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
type FileCardProps = { type FileCardProps = {
file: LibraryFileView; file: LibraryFileView;
token: string; token: string;
/** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */ /** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */
status: DownloadStatus; status: DownloadStatus;
/** 打开图片预览(仅图片文件使用),灯箱由列表统一渲染 */ /** 打开文件:视频进入网页播放器,图片进入预览灯箱;两者都由列表统一渲染 */
onPreview: () => void; onOpen: () => void;
}; };
export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => { export const FileCard = ({ file, token, status, onOpen }: FileCardProps) => {
const origin = useOrigin(); const origin = useOrigin();
const copy = useCopy(); const copy = useCopy();
const [imageFailed, setImageFailed] = useState(false); const [imageFailed, setImageFailed] = useState(false);
@@ -116,61 +170,26 @@ export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => {
file.displayName, file.displayName,
token token
); );
const potPlayerUrl = toPotPlayerUrl(streamUrl);
const available = status === "ready"; const available = status === "ready";
let cover: React.ReactNode; const primary = PRIMARY_ACTION[file.kind];
let primaryAction: React.ReactNode;
let cover: React.ReactNode;
if (!available) { if (!available) {
const action = PENDING_ACTION[file.kind];
cover = ( cover = (
<PlaceholderCover <PlaceholderCover
ext={file.ext} ext={file.ext}
icon={status === "downloading" ? HourglassIcon : CircleOffIcon} icon={status === "downloading" ? HourglassIcon : CircleOffIcon}
/> />
); );
primaryAction = (
<Button
size="sm"
variant={action.variant}
className="flex-1 aria-disabled:opacity-50"
disabled
focusableWhenDisabled
title={
status === "downloading"
? "资源尚未下载完成"
: "文件已不在 TorBox 服务器上"
}
{...CARD_FOCUS_PROPS}
>
<action.icon data-icon="inline-start" />
{action.label}
</Button>
);
} else if (file.kind === "video") { } else if (file.kind === "video") {
cover = <VideoCover ext={file.ext} href={potPlayerUrl} />; cover = <VideoCover ext={file.ext} onPlay={onOpen} />;
primaryAction = ( } else if (file.kind === "image" && !imageFailed) {
<Button cover = (
size="sm"
className="flex-1"
render={<a href={potPlayerUrl} />}
nativeButton={false}
{...CARD_FOCUS_PROPS}
>
<PlayIcon data-icon="inline-start" />
PotPlayer
</Button>
);
} else if (file.kind === "image") {
cover = imageFailed ? (
<PlaceholderCover ext={file.ext} icon={ImageOffIcon} />
) : (
<button <button
type="button" type="button"
title="预览图片" title="预览图片"
className="flex aspect-video overflow-hidden bg-muted" className="flex aspect-video overflow-hidden bg-muted"
onClick={onPreview} onClick={onOpen}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
@@ -182,37 +201,18 @@ export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => {
/> />
</button> </button>
); );
primaryAction = (
<Button
size="sm"
variant="secondary"
className="flex-1"
onClick={onPreview}
{...CARD_FOCUS_PROPS}
>
<EyeIcon data-icon="inline-start" />
预览
</Button>
);
} else { } else {
cover = <PlaceholderCover ext={file.ext} icon={FileIcon} />; cover = (
primaryAction = ( <PlaceholderCover
<Button ext={file.ext}
size="sm" icon={file.kind === "image" ? ImageOffIcon : FileIcon}
variant="secondary" />
className="flex-1"
render={<a href={streamUrl} download={file.displayName} />}
nativeButton={false}
{...CARD_FOCUS_PROPS}
>
<DownloadIcon data-icon="inline-start" />
下载
</Button>
); );
} }
return ( return (
<Card size="sm" className="flex-1 gap-0 py-0"> // 卡片作为容器查询的容器,底栏按卡片自身宽度调整
<Card size="sm" className="@container/card flex-1 gap-0 py-0">
{cover} {cover}
<CardContent className="flex flex-1 flex-col gap-1 py-3"> <CardContent className="flex flex-1 flex-col gap-1 py-3">
<p className="line-clamp-2 leading-snug break-all" title={file.name}> <p className="line-clamp-2 leading-snug break-all" title={file.name}>
@@ -223,23 +223,53 @@ export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => {
</p> </p>
</CardContent> </CardContent>
<CardFooter className="gap-2"> <CardFooter className="gap-2">
{primaryAction} {primary && (
<Tooltip> <Button
<TooltipTrigger size="sm"
render={ variant={primary.variant}
<Button className="flex-1 aria-disabled:opacity-50"
variant="ghost" disabled={!available}
size="icon-sm" focusableWhenDisabled
aria-label="复制直链" title={
disabled={!available} available
onClick={() => copy(streamUrl, "已复制直链")} ? undefined
/> : status === "downloading"
? "资源尚未下载完成"
: "文件已不在 TorBox 服务器上"
} }
onClick={onOpen}
{...CARD_FOCUS_PROPS}
> >
<CopyIcon /> <primary.icon data-icon="inline-start" />
</TooltipTrigger> {/* 卡片窄于 186px(手机两列)时只显示图标,给三个图标按钮留位置 */}
<TooltipContent>复制直链</TooltipContent> <span className="@max-[186px]/card:sr-only">{primary.label}</span>
</Tooltip> </Button>
)}
{/* 次要操作只显示图标;没有主操作时整组靠右 */}
<div className="ml-auto flex items-center gap-1">
{file.kind === "video" && (
<CardIconAction
label="PotPlayer 播放"
icon={MonitorPlayIcon}
href={toPotPlayerUrl(streamUrl)}
disabled={!available}
/>
)}
<CardIconAction
label="下载"
icon={DownloadIcon}
href={streamUrl}
download={file.displayName}
disabled={!available}
focusTarget={!primary}
/>
<CardIconAction
label="复制直链"
icon={CopyIcon}
disabled={!available}
onClick={() => copy(streamUrl, "已复制直链")}
/>
</div>
</CardFooter> </CardFooter>
</Card> </Card>
); );
+1 -1
View File
@@ -7,7 +7,7 @@
export const CARD_PROPS = { "data-file-card": "" } as const; export const CARD_PROPS = { "data-file-card": "" } as const;
export const CARD_SELECTOR = "[data-file-card]"; export const CARD_SELECTOR = "[data-file-card]";
/** 卡片内接收键盘焦点的主操作(PotPlayer / 预览 / 下载) */ /** 卡片内接收键盘焦点的操作:播放 / 预览;其他文件为下载图标 */
export const CARD_FOCUS_PROPS = { "data-card-focus": "" } as const; export const CARD_FOCUS_PROPS = { "data-card-focus": "" } as const;
export const CARD_FOCUS_SELECTOR = "[data-card-focus]"; export const CARD_FOCUS_SELECTOR = "[data-card-focus]";
@@ -8,17 +8,14 @@ import type { LibraryItemView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin"; import { useOrigin } from "@/hooks/use-origin";
import { buildStreamUrl } from "@/utils/media"; import { buildStreamUrl } from "@/utils/media";
import { getItemKey } from "./library-rows"; import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
/** 正在预览的图片:所属资源分组与文件 id */
export type ImagePreviewTarget = { itemKey: string; fileId: number };
type LibraryImagePreviewProps = { type LibraryImagePreviewProps = {
/** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */ /** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */
items: LibraryItemView[]; items: LibraryItemView[];
token: string; token: string;
target: ImagePreviewTarget | null; target: LibraryFileTarget | null;
onTargetChange: (target: ImagePreviewTarget | null) => void; onTargetChange: (target: LibraryFileTarget | null) => void;
}; };
export const LibraryImagePreview = ({ export const LibraryImagePreview = ({
@@ -33,9 +30,7 @@ export const LibraryImagePreview = ({
// 资源未就绪时卡片不提供预览,这里同样视为没有图片 // 资源未就绪时卡片不提供预览,这里同样视为没有图片
const files = useMemo(() => { const files = useMemo(() => {
const item = const item =
itemKey === undefined itemKey === undefined ? undefined : findLibraryItem(items, itemKey);
? undefined
: items.find((entry) => getItemKey(entry) === itemKey);
if (item?.status !== "ready") return []; if (item?.status !== "ready") return [];
return item.files.filter((file) => file.kind === "image"); return item.files.filter((file) => file.kind === "image");
}, [items, itemKey]); }, [items, itemKey]);
+33 -13
View File
@@ -37,14 +37,12 @@ import {
type LibraryScrollSnapshot type LibraryScrollSnapshot
} from "@/stores/library-view-store"; } from "@/stores/library-view-store";
import type { SourceType } from "@/types/torbox"; import type { SourceType } from "@/types/torbox";
import type { MediaKind } from "@/utils/media";
import { FileCard } from "./file-card"; import { FileCard } from "./file-card";
import { FileGrid, FileGridItem } from "./file-grid"; import { FileGrid, FileGridItem } from "./file-grid";
import { ItemHeader } from "./item-header"; import { ItemHeader } from "./item-header";
import { import { LibraryImagePreview } from "./library-image-preview";
LibraryImagePreview,
type ImagePreviewTarget
} from "./library-image-preview";
import { import {
CARD_FOCUS_SELECTOR, CARD_FOCUS_SELECTOR,
CARD_PROPS, CARD_PROPS,
@@ -58,9 +56,11 @@ import {
getColumnCount, getColumnCount,
getRowGroups, getRowGroups,
layoutVirtualRows, layoutVirtualRows,
type LibraryFileTarget,
type LibraryRow, type LibraryRow,
type RowPiece type RowPiece
} from "./library-rows"; } from "./library-rows";
import { LibraryVideoDialog } from "./library-video-dialog";
/** 列表顶部留白;首个分组标题自带 8px 上留白,合计 20px */ /** 列表顶部留白;首个分组标题自带 8px 上留白,合计 20px */
const PADDING_START = 12; const PADDING_START = 12;
@@ -137,6 +137,9 @@ const getStickyTop = (row: LibraryRow) =>
type HeaderRow = Extract<LibraryRow, { type: "header" }>; type HeaderRow = Extract<LibraryRow, { type: "header" }>;
/** 打开的文件:图片进入预览灯箱,视频进入网页播放弹窗 */
type OpenedFile = LibraryFileTarget & { kind: MediaKind };
type RowViewProps = { type RowViewProps = {
row: LibraryRow; row: LibraryRow;
index: number; index: number;
@@ -145,7 +148,7 @@ type RowViewProps = {
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
measure: (element: HTMLDivElement | null) => void; measure: (element: HTMLDivElement | null) => void;
onToggle: (row: HeaderRow, index: number) => void; onToggle: (row: HeaderRow, index: number) => void;
onPreview: (target: ImagePreviewTarget) => void; onOpen: (file: OpenedFile) => void;
}; };
/** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */ /** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */
@@ -157,7 +160,7 @@ const RowView = memo(function RowView({
scrollRef, scrollRef,
measure, measure,
onToggle, onToggle,
onPreview onOpen
}: RowViewProps) { }: RowViewProps) {
const [stuckRef, stuck] = useStuck<HTMLDivElement>( const [stuckRef, stuck] = useStuck<HTMLDivElement>(
scrollRef, scrollRef,
@@ -199,8 +202,12 @@ const RowView = memo(function RowView({
file={file} file={file}
token={token} token={token}
status={row.status} status={row.status}
onPreview={() => onOpen={() =>
onPreview({ itemKey: row.itemKey, fileId: file.id }) onOpen({
itemKey: row.itemKey,
fileId: file.id,
kind: file.kind
})
} }
/> />
</FileGridItem> </FileGridItem>
@@ -365,8 +372,15 @@ const VirtualRows = ({
[virtualizer, toggleItem] [virtualizer, toggleItem]
); );
// 图片预览:灯箱只渲染一个,按分组取图片 // 图片灯箱与视频弹窗各只渲染一个,不放在卡片里:
const [preview, setPreview] = useState<ImagePreviewTarget | null>(null); // 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放
const [opened, setOpened] = useState<OpenedFile | null>(null);
const closeOpened = useCallback(() => setOpened(null), []);
const changePreview = useCallback(
(target: LibraryFileTarget | null) =>
setOpened(target && { ...target, kind: "image" }),
[]
);
// 卸载时(如列表被空状态替换)补存一次快照 // 卸载时(如列表被空状态替换)补存一次快照
useEffect( useEffect(
@@ -420,7 +434,7 @@ const VirtualRows = ({
scrollRef={scrollRef} scrollRef={scrollRef}
measure={virtualizer.measureElement} measure={virtualizer.measureElement}
onToggle={onToggleHeader} onToggle={onToggleHeader}
onPreview={setPreview} onOpen={setOpened}
/> />
); );
const renderPiece = (piece: RowPiece) => const renderPiece = (piece: RowPiece) =>
@@ -457,8 +471,14 @@ const VirtualRows = ({
<LibraryImagePreview <LibraryImagePreview
items={items} items={items}
token={token} token={token}
target={preview} target={opened?.kind === "image" ? opened : null}
onTargetChange={setPreview} onTargetChange={changePreview}
/>
<LibraryVideoDialog
items={items}
token={token}
target={opened?.kind === "video" ? opened : null}
onClose={closeOpened}
/> />
</> </>
); );
+7
View File
@@ -43,6 +43,13 @@ export type LibraryRow =
export const getItemKey = (item: LibraryItemView) => export const getItemKey = (item: LibraryItemView) =>
`${item.source}-${item.id}`; `${item.source}-${item.id}`;
/** 列表中的某个文件:所属资源分组与文件 id(文件 id 只在资源内唯一) */
export type LibraryFileTarget = { itemKey: string; fileId: number };
/** 按分组 key 查找资源;找不到时返回 undefined */
export const findLibraryItem = (items: LibraryItemView[], itemKey: string) =>
items.find((item) => getItemKey(item) === itemKey);
/** /**
* 把分组数据拍平成行。行 key 只依赖资源与分块序号、不依赖列数, * 把分组数据拍平成行。行 key 只依赖资源与分块序号、不依赖列数,
* 保证刷新、插入新资源、窗口缩放时锚点行仍可定位。 * 保证刷新、插入新资源、窗口缩放时锚点行仍可定位。
@@ -0,0 +1,130 @@
// 资源库视频网页播放弹窗
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { DownloadIcon, MonitorPlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
import type { LibraryItemView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
import { formatBytes } from "@/utils/format";
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
/** 16:9,矮屏时限制高度给标题与操作栏留位置 */
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
/** 播放器较大,打开弹窗时才加载 */
const VideoPlayer = dynamic(
() => import("@/components/video-player").then((mod) => mod.VideoPlayer),
{
ssr: false,
loading: () => <Skeleton className={PLAYER_CLASS} />
}
);
type LibraryVideoDialogProps = {
items: LibraryItemView[];
token: string;
target: LibraryFileTarget | null;
onClose: () => void;
};
/**
* 视频默认在网页内播放:MP4、WebM 直接播放原文件,其他格式经 TorBox 转码;
* 仍然播不了时可以一键换 PotPlayer
*/
export const LibraryVideoDialog = ({
items,
token,
target,
onClose
}: LibraryVideoDialogProps) => {
const origin = useOrigin();
// 关闭动画期间 target 已清空,继续显示刚才的视频
const [shown, setShown] = useState(target);
if (target && target !== shown) setShown(target);
const item = shown ? findLibraryItem(items, shown.itemKey) : undefined;
const file =
item?.status === "ready"
? item.files.find((entry) => entry.id === shown?.fileId)
: undefined;
const streamUrl =
file && origin
? buildStreamUrl(origin, file.playUrl, file.displayName, token)
: null;
// 浏览器解不了的格式改用 TorBox 转码流;下载和 PotPlayer 仍用原文件
const hlsUrl =
item && file && origin && !BROWSER_VIDEO_EXT.has(file.ext)
? buildHlsUrl(origin, item.source, item.id, file.id, token)
: null;
return (
<Dialog
open={target !== null && streamUrl !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
{file && streamUrl && (
<DialogContent className="grid-cols-1 gap-3 sm:max-w-5xl">
{/* 右侧留出关闭按钮的位置 */}
<DialogHeader className="gap-1 pr-8">
<DialogTitle className="truncate leading-snug" title={file.name}>
{file.displayName}
</DialogTitle>
<DialogDescription className="font-mono text-xs tabular-nums">
<span className="uppercase">{file.ext || "file"}</span> ·{" "}
{formatBytes(file.size)}
{hlsUrl && " · TorBox 转码"}
</DialogDescription>
</DialogHeader>
<VideoPlayer
key={hlsUrl ?? streamUrl}
src={hlsUrl ?? streamUrl}
hls={hlsUrl !== null}
title={file.displayName}
autoPlay
autoFocus
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
className={PLAYER_CLASS}
/>
<DialogFooter className="sm:items-center">
<Button
variant="outline"
size="sm"
render={<a href={streamUrl} download={file.displayName} />}
nativeButton={false}
>
<DownloadIcon data-icon="inline-start" />
下载
</Button>
{/* 交给外部播放器后关闭弹窗,避免两边同时播放 */}
<Button
size="sm"
render={<a href={toPotPlayerUrl(streamUrl)} />}
nativeButton={false}
onClick={onClose}
>
<MonitorPlayIcon data-icon="inline-start" />
PotPlayer 播放
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
@@ -0,0 +1,56 @@
// 转码播放列表接口:浏览器解不了的视频由 TorBox 转码为 HLS
import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth";
import { createStream, isSourceType } from "@/server/torbox";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
/**
* TorBox 的播放列表地址带着 API Key,所以由服务端取回列表再返回。
* 列表里的分片是相对地址,访问时不需要 Key:改写成 CDN 上的绝对地址后,
* 浏览器直接从 CDN 取分片(CDN 允许跨域),视频数据不经过本服务。
*/
export async function GET(req: NextRequest, { params }: Ctx) {
const denied = guard(req);
if (denied) return denied;
const { type, id, fileId } = await params;
if (!isSourceType(type) || !/^\d+$/.test(id) || !/^\d+$/.test(fileId)) {
return jsonError(404, "unknown source");
}
try {
const playlistUrl = await createStream(type, id, fileId);
const upstream = await fetch(playlistUrl, {
signal: req.signal,
cache: "no-store"
});
if (!upstream.ok)
return jsonError(502, `TorBox 播放列表请求失败(${upstream.status})`);
// 相对地址按列表地址解析,不带列表地址上的查询参数(即 API Key)
const playlist = (await upstream.text())
.split("\n")
.map((line) => {
const uri = line.trim();
return uri && !uri.startsWith("#")
? new URL(uri, playlistUrl).href
: line;
})
.join("\n");
return new Response(playlist, {
headers: {
"Content-Type": "application/vnd.apple.mpegurl",
"Cache-Control": "no-store"
}
});
} catch (error) {
return jsonError(
502,
error instanceof Error ? error.message : "upstream error"
);
}
}
+39 -9
View File
@@ -4,13 +4,24 @@
import { useEffect, useImperativeHandle, useRef } from "react"; import { useEffect, useImperativeHandle, useRef } from "react";
import { useReducedMotion } from "motion/react"; import { useReducedMotion } from "motion/react";
import { DOT_FIELD, RIPPLES, type RippleName, type RippleTone } from "@/constants/motion"; import {
import { createDotField, type DotField as DotFieldEngine } from "@/lib/dot-field"; DOT_FIELD,
RIPPLES,
type RippleName,
type RippleTone
} from "@/constants/motion";
import {
createDotField,
type DotField as DotFieldEngine
} from "@/lib/dot-field";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type DotFieldHandle = { export type DotFieldHandle = {
/** 从元素中心或画布坐标扩散一次涟漪;动效关闭时立即 resolve */ /** 从元素中心或画布坐标扩散一次涟漪;动效关闭时立即 resolve */
ripple: (origin: Element | { x: number; y: number }, name: RippleName) => Promise<void>; ripple: (
origin: Element | { x: number; y: number },
name: RippleName
) => Promise<void>;
}; };
type DotFieldProps = { type DotFieldProps = {
@@ -26,7 +37,10 @@ const TONE_VARS: Record<RippleTone, string> = {
const readColors = (element: HTMLElement) => { const readColors = (element: HTMLElement) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
return Object.fromEntries( return Object.fromEntries(
Object.entries(TONE_VARS).map(([tone, name]) => [tone, style.getPropertyValue(name).trim()]) Object.entries(TONE_VARS).map(([tone, name]) => [
tone,
style.getPropertyValue(name).trim()
])
) as Record<RippleTone, string>; ) as Record<RippleTone, string>;
}; };
@@ -43,11 +57,15 @@ export const DotField = ({ ref, className }: DotFieldProps) => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || reduced) return; if (!canvas || reduced) return;
const engine = createDotField(canvas, { ...DOT_FIELD, colors: readColors(canvas) }); const engine = createDotField(canvas, {
...DOT_FIELD,
colors: readColors(canvas)
});
engineRef.current = engine; engineRef.current = engine;
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
engine.resize(entry.contentRect.width, entry.contentRect.height, window.devicePixelRatio || 1); const dpr = Math.min(window.devicePixelRatio || 1, DOT_FIELD.maxDpr);
engine.resize(entry.contentRect.width, entry.contentRect.height, dpr);
}); });
observer.observe(canvas); observer.observe(canvas);
@@ -63,7 +81,10 @@ export const DotField = ({ ref, className }: DotFieldProps) => {
return () => { return () => {
observer.disconnect(); observer.disconnect();
window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave); document.documentElement.removeEventListener(
"pointerleave",
onPointerLeave
);
engine.destroy(); engine.destroy();
engineRef.current = null; engineRef.current = null;
}; };
@@ -78,11 +99,20 @@ export const DotField = ({ ref, className }: DotFieldProps) => {
let point = origin as { x: number; y: number }; let point = origin as { x: number; y: number };
if (origin instanceof Element) { if (origin instanceof Element) {
const rect = origin.getBoundingClientRect(); const rect = origin.getBoundingClientRect();
point = { x: rect.left + rect.width / 2 - bounds.left, y: rect.top + rect.height / 2 - bounds.top }; point = {
x: rect.left + rect.width / 2 - bounds.left,
y: rect.top + rect.height / 2 - bounds.top
};
} }
return engine.ripple(point, RIPPLES[name]); return engine.ripple(point, RIPPLES[name]);
} }
})); }));
return <canvas ref={canvasRef} aria-hidden="true" className={cn("pointer-events-none block size-full", className)} />; return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn("pointer-events-none block size-full", className)}
/>
);
}; };
+88
View File
@@ -0,0 +1,88 @@
// 网页视频播放器:Video.js v10 + 项目内安装的 shadcn 皮肤
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import dynamic from "next/dynamic";
import { I18nProvider } from "@videojs/react";
import zhCN from "@videojs/react/i18n/locales/zh-CN";
import { Video, VideoPlayer as Player } from "@videojs/react/video";
import { VideoSkin } from "@/components/videojs/video/skin";
/**
* HLS 播放用 hls.js 引擎:Video.js 自带的 HlsVideo 不支持 MPEG-TS 分片。
* hls.js 体积较大,只在播放 HLS 时加载。
*/
const HlsJsVideo = dynamic(
() =>
import("@videojs/react/media/hlsjs-video").then((mod) => mod.HlsJsVideo),
{ ssr: false }
);
type VideoPlayerProps = {
src: string;
/** src 为 HLS 播放列表(m3u8) */
hls?: boolean;
/** 显示在画面顶部(控制栏出现时),全屏时也能看到 */
title?: string;
/** 由用户点击打开时可直接有声播放 */
autoPlay?: boolean;
/** 挂载后把焦点交给播放器,空格、方向键等快捷键直接可用(快捷键只在焦点位于播放器内时生效) */
autoFocus?: boolean;
/** 替换默认的播放错误说明 */
errorDescription?: ReactNode;
/** 尺寸与比例,如 aspect-video */
className?: string;
};
/**
* 通用视频播放器:控制栏、快捷键、手势、错误提示都由皮肤提供。
* 没有 I18nProvider 时皮肤一律显示英文,这里固定为中文并直接传入语言包,不走异步加载。
* 切换视频时由调用方以 src 作为 key 重新挂载,避免沿用上一个视频的播放状态。
*/
export const VideoPlayer = ({
src,
hls,
title,
autoPlay,
autoFocus,
errorDescription,
className
}: VideoPlayerProps) => {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!autoFocus) return;
// 皮肤的容器(.media-skin)可聚焦,但组件没有暴露 ref
rootRef.current
?.querySelector<HTMLElement>(".media-skin")
?.focus({ preventScroll: true });
}, [autoFocus]);
return (
// contents:只用来取到皮肤容器,不参与布局
<div ref={rootRef} className="contents">
<I18nProvider locale="zh-CN" translations={zhCN}>
<Player title={title}>
<VideoSkin className={className} errorDescription={errorDescription}>
{hls ? (
<HlsJsVideo
src={src}
autoPlay={autoPlay}
playsInline
preload="metadata"
/>
) : (
<Video
src={src}
autoPlay={autoPlay}
playsInline
preload="metadata"
/>
)}
</VideoSkin>
</Player>
</I18nProvider>
</div>
);
};
+10 -2
View File
@@ -5,11 +5,17 @@ import '../styles/audio/theme.css';
import '../styles/video/captions.css'; import '../styles/video/captions.css';
import '../styles/video/theme.css'; import '../styles/video/theme.css';
import { ErrorDialog as ErrorDialogPrimitive } from '@videojs/react'; import { ErrorDialog as ErrorDialogPrimitive } from '@videojs/react';
import type { ReactNode } from 'react';
import { Button } from '@/components/videojs/ui/button'; import { Button } from '@/components/videojs/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function ErrorDialog() { export interface ErrorDialogProps {
/** Replaces the default error message (e.g. to suggest another way to play). */
description?: ReactNode;
}
export function ErrorDialog({ description }: ErrorDialogProps = {}) {
return ( return (
<ErrorDialogPrimitive.Root> <ErrorDialogPrimitive.Root>
<ErrorDialogPrimitive.Backdrop <ErrorDialogPrimitive.Backdrop
@@ -32,7 +38,9 @@ export function ErrorDialog() {
> >
<div className={cn('flex min-h-0 flex-col gap-2 overflow-y-auto', 'px-2 pt-2 pb-1.5')}> <div className={cn('flex min-h-0 flex-col gap-2 overflow-y-auto', 'px-2 pt-2 pb-1.5')}>
<ErrorDialogPrimitive.Title className={'m-0 text-media-lg font-semibold leading-tight'} /> <ErrorDialogPrimitive.Title className={'m-0 text-media-lg font-semibold leading-tight'} />
<ErrorDialogPrimitive.Description className={'m-0 opacity-70 wrap-anywhere'} /> <ErrorDialogPrimitive.Description className={'m-0 opacity-70 wrap-anywhere'}>
{description}
</ErrorDialogPrimitive.Description>
</div> </div>
<div className={'flex shrink-0 gap-2'}> <div className={'flex shrink-0 gap-2'}>
<ErrorDialogPrimitive.Close <ErrorDialogPrimitive.Close
+11 -2
View File
@@ -19,9 +19,18 @@ export interface VideoSkinProps extends Omit<NonNullable<ComponentProps<typeof C
children?: ReactNode; children?: ReactNode;
renderPoster?: NonNullable<ComponentProps<typeof Poster>>['renderImage']; renderPoster?: NonNullable<ComponentProps<typeof Poster>>['renderImage'];
renderThumbnail?: NonNullable<ComponentProps<typeof DefaultVideoControls>>['renderThumbnail']; renderThumbnail?: NonNullable<ComponentProps<typeof DefaultVideoControls>>['renderThumbnail'];
/** Replaces the default playback error message. */
errorDescription?: ReactNode;
} }
export function VideoSkin({ children, className, renderPoster, renderThumbnail, ...props }: VideoSkinProps = {}) { export function VideoSkin({
children,
className,
renderPoster,
renderThumbnail,
errorDescription,
...props
}: VideoSkinProps = {}) {
return ( return (
<Container <Container
className={cn('pointer-fine:not-data-controls-visible:cursor-none', className)} className={cn('pointer-fine:not-data-controls-visible:cursor-none', className)}
@@ -32,7 +41,7 @@ export function VideoSkin({ children, className, renderPoster, renderThumbnail,
{children} {children}
<Poster renderImage={renderPoster} /> <Poster renderImage={renderPoster} />
<BufferingIndicator /> <BufferingIndicator />
<ErrorDialog /> <ErrorDialog description={errorDescription} />
<Title /> <Title />
<DefaultVideoControls renderThumbnail={renderThumbnail} /> <DefaultVideoControls renderThumbnail={renderThumbnail} />
+3 -1
View File
@@ -92,7 +92,9 @@ export const DOT_FIELD = {
decayMs: 350, decayMs: 350,
/** 点亮后的最大半径(px) */ /** 点亮后的最大半径(px) */
litRadius: 2.2, litRadius: 2.2,
baseRadius: 1 baseRadius: 1,
/** 画布像素比上限:点只有 1~2px,更高的像素比只会让栅格化开销成倍增加 */
maxDpr: 2
} as const; } as const;
export type RippleTone = "primary" | "destructive"; export type RippleTone = "primary" | "destructive";
+25 -3
View File
@@ -3,10 +3,26 @@ import type { SourceType } from "@/types/torbox";
export const TORBOX_API = "https://api.torbox.app/v1/api"; export const TORBOX_API = "https://api.torbox.app/v1/api";
/** streamType:转码流接口(/stream/createstream)中的类型名,与列表接口不同 */
export const SOURCES = [ export const SOURCES = [
{ type: "torrents", list: "/torrents/mylist", idName: "torrent_id" }, {
{ type: "usenet", list: "/usenet/mylist", idName: "usenet_id" }, type: "torrents",
{ type: "webdl", list: "/webdl/mylist", idName: "web_id" } list: "/torrents/mylist",
idName: "torrent_id",
streamType: "torrent"
},
{
type: "usenet",
list: "/usenet/mylist",
idName: "usenet_id",
streamType: "usenet"
},
{
type: "webdl",
list: "/webdl/mylist",
idName: "web_id",
streamType: "webdownload"
}
] as const; ] as const;
export const IMAGE_EXT = new Set([ export const IMAGE_EXT = new Set([
@@ -29,6 +45,12 @@ export const IMAGE_EXT = new Set([
"heif" "heif"
]); ]);
/**
* 浏览器能直接播放的视频格式,网页内直接读取原文件;
* 其他视频(WMV、AVI、MKV 等)由 TorBox 转码为 HLS 后播放。
*/
export const BROWSER_VIDEO_EXT = new Set(["mp4", "m4v", "webm"]);
export const VIDEO_EXT = new Set([ export const VIDEO_EXT = new Set([
"mp4", "mp4",
"webm", "webm",
+28 -4
View File
@@ -164,16 +164,16 @@ export const getLibrary = async ({
return { items, errors }; return { items, errors };
}; };
const MOCK_PLAY_ERROR =
"演示模式没有可播放的文件,配置 TORBOX_API_KEY 后可播放真实资源";
/** 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端) */ /** 向 TorBox 换取临时直链(仅服务端使用,不下发给客户端) */
export const requestDownloadUrl = async ( export const requestDownloadUrl = async (
type: SourceType, type: SourceType,
id: string, id: string,
fileId: string fileId: string
) => { ) => {
if (isMockMode()) if (isMockMode()) throw new Error(MOCK_PLAY_ERROR);
throw new Error(
"演示模式没有可播放的文件,配置 TORBOX_API_KEY 后可播放真实资源"
);
const source = SOURCES.find((s) => s.type === type)!; const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({ const params = new URLSearchParams({
token: apiKey(), token: apiKey(),
@@ -185,3 +185,27 @@ export const requestDownloadUrl = async (
if (typeof url !== "string" || !url) throw new Error("TorBox 未返回下载地址"); if (typeof url !== "string" || !url) throw new Error("TorBox 未返回下载地址");
return url; return url;
}; };
/**
* 创建 TorBox 网页转码流(Pro 账号功能),返回 HLS 播放列表地址。
* 地址的 token 参数就是 API Key,只在服务端使用,不下发给客户端。
* 字幕、音轨、清晰度不传,即无字幕、第一条音轨、原始分辨率。
*/
export const createStream = async (
type: SourceType,
id: string,
fileId: string
) => {
if (isMockMode()) throw new Error(MOCK_PLAY_ERROR);
const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({
id,
file_id: fileId,
type: source.streamType
});
const data = await torboxFetch(`/stream/createstream?${params}`);
const url = data?.hls_url;
if (typeof url !== "string" || !url)
throw new Error("TorBox 未返回转码播放地址");
return url;
};
+18 -1
View File
@@ -1,5 +1,6 @@
// 媒体文件类型与路径处理函数 // 媒体文件类型与路径处理函数
import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox"; import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox";
import type { SourceType } from "@/types/torbox";
export type MediaKind = "video" | "image" | "other"; export type MediaKind = "video" | "image" | "other";
@@ -18,7 +19,10 @@ export const getFileExt = (name: string) => {
* 缺失、为通用二进制类型(application/octet-stream)或其他类型时按扩展名兜底。 * 缺失、为通用二进制类型(application/octet-stream)或其他类型时按扩展名兜底。
* 例如 rmvb 常被识别为 application/vnd.rn-realmedia,按扩展名仍归为视频。 * 例如 rmvb 常被识别为 application/vnd.rn-realmedia,按扩展名仍归为视频。
*/ */
export const getMediaKind = (name: string, mimetype?: string | null): MediaKind => { export const getMediaKind = (
name: string,
mimetype?: string | null
): MediaKind => {
const type = mimetype?.trim().toLowerCase() ?? ""; const type = mimetype?.trim().toLowerCase() ?? "";
if (type.startsWith("video/")) return "video"; if (type.startsWith("video/")) return "video";
if (type.startsWith("image/")) return "image"; if (type.startsWith("image/")) return "image";
@@ -43,6 +47,19 @@ export const buildStreamUrl = (
return url.toString(); return url.toString();
}; };
/** TorBox 转码流(HLS)的播放列表地址,由服务端转发 */
export const buildHlsUrl = (
origin: string,
source: SourceType,
itemId: number,
fileId: number,
token: string
) => {
const url = new URL(`/api/hls/${source}/${itemId}/${fileId}`, origin);
url.searchParams.set("token", token);
return url.toString();
};
export const toPotPlayerUrl = (streamUrl: string) => `potplayer://${streamUrl}`; export const toPotPlayerUrl = (streamUrl: string) => `potplayer://${streamUrl}`;
/** 全部资源的 M3U 播放列表地址,供 PotPlayer 等播放器直接打开 */ /** 全部资源的 M3U 播放列表地址,供 PotPlayer 等播放器直接打开 */