feat: 接入torbox视频转码
This commit is contained in:
+10
-6
@@ -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` |
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+22
-2
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 等播放器直接打开 */
|
||||||
|
|||||||
Reference in New Issue
Block a user