diff --git a/docs/design-system.md b/docs/design-system.md index bbc8dfa..415ac92 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -4,7 +4,7 @@ TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页资源并存入云端,再以直链或串流取回。官网自称 _Private Infrastructure Provider_,视觉为墨黑底、翠绿强调色、点阵标题字体、6px 圆角。 -本项目是自托管的 TorBox 面板,核心任务是**浏览资源并一键交给 PotPlayer 串流**。风格定为 **「私有云控制台」**: +本项目是自托管的 TorBox 面板,核心任务是**浏览资源并直接播放**:视频默认在网页内播放,PotPlayer 作为扩展的打开方式。风格定为 **「私有云控制台」**: - 墨黑底 + 翠绿**唯一**强调色,克制、不花哨 - 点阵字标呼应 TorBox 品牌,等宽字体承载一切数据 @@ -100,10 +100,11 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | 表单 | `FieldGroup` + `Field` + `FieldLabel` + `InputGroup` | | 2–5 个选项切换 | `ToggleGroup`(`variant="outline" size="sm" spacing={0}`) | | 资源分组 | `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`) | | 下拉操作 | `DropdownMenu` | | 图标按钮说明 | `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):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | | 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) | | 错误提示 | `Alert variant="destructive"` | @@ -114,8 +115,9 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx 按钮层级: -- `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「PotPlayer」「进入」。 -- `secondary`——次要但常用,如「预览」「下载」。 +- `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「播放」「进入」。 +- `secondary`——次要但常用,如「预览」。 +- 卡片上的下载、复制直链、PotPlayer 一律是 `ghost` + `icon-sm` 图标按钮,不作为主操作。 - `outline` / `ghost`——工具栏与 header 中的辅助操作、图标按钮。 ## 7. 状态 @@ -124,7 +126,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | ------------ | ------------------------------------------------------------ | | 加载中 | `Skeleton` 复刻真实布局(分组标题 + 卡片网格) | | 刷新中 | 刷新按钮内 `Spinner` 并禁用,列表保持原内容;手动刷新带 `?fresh=1`,服务端以 `bypass_cache` 绕过 TorBox 列表缓存(torrent 默认 600 秒才更新),自动重新获取仍走缓存 | -| 资源下载中 | 标题行显示 `Progress` + 百分比 + 状态名(`DOWNLOAD_STATE_LABELS`);卡片封面为沙漏占位,主操作禁用但可聚焦(`focusableWhenDisabled`),复制直链禁用;不进入 M3U 播放列表 | +| 资源下载中 | 标题行显示 `Progress` + 百分比 + 状态名(`DOWNLOAD_STATE_LABELS`);卡片封面为沙漏占位,主操作(没有主操作时为下载图标)禁用但可聚焦(`focusableWhenDisabled`),其余图标操作禁用;不进入 M3U 播放列表 | | 资源已失效 | 下载完成但文件不在服务器上:标题行 `destructive` 色「文件不可用」,卡片同上禁用 | | 资源快过期 | 距 `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` 提示演示密钥 | @@ -142,6 +144,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | ---------- | ---------------- | | 品牌 | `Box` | | 播放 | `Play` | +| PotPlayer | `MonitorPlay` | | 复制直链 | `Copy` | | 图片预览 | `Eye` | | 下载 | `Download` | @@ -175,7 +178,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | `src/utils/` | `scramble`、`grid-nav`、`layout`、`date`、`sort` | 纯函数,无 React、无业务 | | `src/lib/dot-field.ts` | 画布点阵场引擎 | 不依赖 React;渲染循环用 Motion `frame`,涟漪时序用 `animate` | | `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` 等 | 只做接线:传选择器、参数与回调 | - 容器级交互 hook 统一签名 `useXxx(containerRef, options)`,在容器上做事件委托,彼此独立;删掉一行调用即可拆掉一个交互。 @@ -197,6 +200,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 | | 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 | | 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` | +| 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 | | 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 | | 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` | diff --git a/package.json b/package.json index 3f5b6e0..619b195 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.103.2", "@tanstack/react-virtual": "^3.14.13", "@videojs/core": "10.0.1", + "@videojs/hlsjs-video": "10.0.1", "@videojs/react": "10.0.1", "axios": "^1.20.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e433010..7ecccaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,12 @@ importers: '@videojs/core': specifier: 10.0.1 version: 10.0.1(react@19.2.8) + '@videojs/hlsjs-video': + specifier: 10.0.1 + version: 10.0.1 '@videojs/react': 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: specifier: ^1.20.0 version: 1.20.0 @@ -946,6 +949,9 @@ packages: '@videojs/core@10.0.1': resolution: {integrity: sha512-N+f41wgFcmpe0QUhO0Q9sFHpMxKvr38ojkX5FlxAo3LKDAo2UrG2KIeklte+1VVae3oaJfP6J/KYtrRRObRxxw==} + '@videojs/hlsjs-video@10.0.1': + resolution: {integrity: sha512-7Om99Vxsh7JYAVf04QO4SKgkIE2jrZ8c3KS5BtdQRqZaFYM5uWU9vubw3vwerI5nsrosg1KXcuzS5TGEUW4ToQ==} + '@videojs/media@10.0.1': resolution: {integrity: sha512-hMC2aS/8PovwJBi8y2qShC/amYfH6SBce7SZAM54qzRq8hl4FwWGFeUMMmXp8x4agRxLuCSqGqZg/EoGH2I5mQ==} @@ -1917,6 +1923,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hls.js@1.6.7: + resolution: {integrity: sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==} + hono@4.13.8: resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==} engines: {node: '>=16.9.0'} @@ -4216,6 +4225,13 @@ snapshots: - '@videojs/element' - 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': dependencies: '@videojs/utils': 10.0.1 @@ -4225,7 +4241,7 @@ snapshots: '@videojs/media': 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: '@videojs/core': 10.0.1(react@19.2.8) '@videojs/media': 10.0.1 @@ -4236,6 +4252,8 @@ snapshots: react: 19.2.8 react-compiler-runtime: 1.0.0(react@19.2.8) react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@videojs/hlsjs-video': 10.0.1 transitivePeerDependencies: - '@videojs/element' @@ -5338,6 +5356,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hls.js@1.6.7: {} + hono@4.13.8: {} http-errors@2.0.1: diff --git a/src/app/(app)/components/file-card.tsx b/src/app/(app)/components/file-card.tsx index d03c2ee..059311d 100644 --- a/src/app/(app)/components/file-card.tsx +++ b/src/app/(app)/components/file-card.tsx @@ -11,6 +11,7 @@ import { FileIcon, HourglassIcon, ImageOffIcon, + MonitorPlayIcon, PlayIcon, type LucideIcon } from "lucide-react"; @@ -28,6 +29,7 @@ import { useCopy } from "@/hooks/use-copy"; import type { LibraryFileView } from "@/hooks/use-library"; import { useOrigin } from "@/hooks/use-origin"; import { useSpotlight } from "@/hooks/use-spotlight"; +import { cn } from "@/lib/utils"; import { formatBytes } from "@/utils/format"; import type { DownloadStatus } from "@/types/torbox"; 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); return ( - - + + ); }; -/** 资源未就绪时,主操作按钮保持原样式但禁用(仍可被键盘聚焦,方向键导航不中断) */ -const PENDING_ACTION: Record< - MediaKind, - { icon: LucideIcon; label: string; variant: "default" | "secondary" } +/** 主操作:视频在网页内播放、图片预览;其他文件没有主操作,只有图标操作 */ +const PRIMARY_ACTION: Partial< + Record< + MediaKind, + { icon: LucideIcon; label: string; variant: "default" | "secondary" } + > > = { - video: { icon: PlayIcon, label: "PotPlayer", variant: "default" }, - image: { icon: EyeIcon, label: "预览", variant: "secondary" }, - other: { icon: DownloadIcon, label: "下载", variant: "secondary" } + video: { icon: PlayIcon, label: "播放", variant: "default" }, + image: { icon: EyeIcon, 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) => ( + + + } + : {})} + {...(focusTarget ? CARD_FOCUS_PROPS : {})} + /> + } + > + + + {label} + +); + type FileCardProps = { file: LibraryFileView; token: string; /** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */ 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 copy = useCopy(); const [imageFailed, setImageFailed] = useState(false); @@ -116,61 +170,26 @@ export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => { file.displayName, token ); - const potPlayerUrl = toPotPlayerUrl(streamUrl); - const available = status === "ready"; - let cover: React.ReactNode; - let primaryAction: React.ReactNode; + const primary = PRIMARY_ACTION[file.kind]; + let cover: React.ReactNode; if (!available) { - const action = PENDING_ACTION[file.kind]; cover = ( ); - primaryAction = ( - - ); } else if (file.kind === "video") { - cover = ; - primaryAction = ( - - ); - } else if (file.kind === "image") { - cover = imageFailed ? ( - - ) : ( + cover = ; + } else if (file.kind === "image" && !imageFailed) { + cover = ( ); - primaryAction = ( - - ); } else { - cover = ; - primaryAction = ( - + cover = ( + ); } return ( - + // 卡片作为容器查询的容器,底栏按卡片自身宽度调整 + {cover}

@@ -223,23 +223,53 @@ export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => {

- {primaryAction} - - copy(streamUrl, "已复制直链")} - /> + {primary && ( + + )} + {/* 次要操作只显示图标;没有主操作时整组靠右 */} +
+ {file.kind === "video" && ( + + )} + + copy(streamUrl, "已复制直链")} + /> +
); diff --git a/src/app/(app)/components/library-dom.ts b/src/app/(app)/components/library-dom.ts index eb03812..e691746 100644 --- a/src/app/(app)/components/library-dom.ts +++ b/src/app/(app)/components/library-dom.ts @@ -7,7 +7,7 @@ export const CARD_PROPS = { "data-file-card": "" } as const; export const CARD_SELECTOR = "[data-file-card]"; -/** 卡片内接收键盘焦点的主操作(PotPlayer / 预览 / 下载) */ +/** 卡片内接收键盘焦点的操作:播放 / 预览;其他文件为下载图标 */ export const CARD_FOCUS_PROPS = { "data-card-focus": "" } as const; export const CARD_FOCUS_SELECTOR = "[data-card-focus]"; diff --git a/src/app/(app)/components/library-image-preview.tsx b/src/app/(app)/components/library-image-preview.tsx index a9c849e..602d7f4 100644 --- a/src/app/(app)/components/library-image-preview.tsx +++ b/src/app/(app)/components/library-image-preview.tsx @@ -8,17 +8,14 @@ import type { LibraryItemView } from "@/hooks/use-library"; import { useOrigin } from "@/hooks/use-origin"; import { buildStreamUrl } from "@/utils/media"; -import { getItemKey } from "./library-rows"; - -/** 正在预览的图片:所属资源分组与文件 id */ -export type ImagePreviewTarget = { itemKey: string; fileId: number }; +import { findLibraryItem, type LibraryFileTarget } from "./library-rows"; type LibraryImagePreviewProps = { /** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */ items: LibraryItemView[]; token: string; - target: ImagePreviewTarget | null; - onTargetChange: (target: ImagePreviewTarget | null) => void; + target: LibraryFileTarget | null; + onTargetChange: (target: LibraryFileTarget | null) => void; }; export const LibraryImagePreview = ({ @@ -33,9 +30,7 @@ export const LibraryImagePreview = ({ // 资源未就绪时卡片不提供预览,这里同样视为没有图片 const files = useMemo(() => { const item = - itemKey === undefined - ? undefined - : items.find((entry) => getItemKey(entry) === itemKey); + itemKey === undefined ? undefined : findLibraryItem(items, itemKey); if (item?.status !== "ready") return []; return item.files.filter((file) => file.kind === "image"); }, [items, itemKey]); diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx index 8945697..a865c71 100644 --- a/src/app/(app)/components/library-list.tsx +++ b/src/app/(app)/components/library-list.tsx @@ -37,14 +37,12 @@ import { type LibraryScrollSnapshot } from "@/stores/library-view-store"; import type { SourceType } from "@/types/torbox"; +import type { MediaKind } from "@/utils/media"; import { FileCard } from "./file-card"; import { FileGrid, FileGridItem } from "./file-grid"; import { ItemHeader } from "./item-header"; -import { - LibraryImagePreview, - type ImagePreviewTarget -} from "./library-image-preview"; +import { LibraryImagePreview } from "./library-image-preview"; import { CARD_FOCUS_SELECTOR, CARD_PROPS, @@ -58,9 +56,11 @@ import { getColumnCount, getRowGroups, layoutVirtualRows, + type LibraryFileTarget, type LibraryRow, type RowPiece } from "./library-rows"; +import { LibraryVideoDialog } from "./library-video-dialog"; /** 列表顶部留白;首个分组标题自带 8px 上留白,合计 20px */ const PADDING_START = 12; @@ -137,6 +137,9 @@ const getStickyTop = (row: LibraryRow) => type HeaderRow = Extract; +/** 打开的文件:图片进入预览灯箱,视频进入网页播放弹窗 */ +type OpenedFile = LibraryFileTarget & { kind: MediaKind }; + type RowViewProps = { row: LibraryRow; index: number; @@ -145,7 +148,7 @@ type RowViewProps = { scrollRef: React.RefObject; measure: (element: HTMLDivElement | null) => void; onToggle: (row: HeaderRow, index: number) => void; - onPreview: (target: ImagePreviewTarget) => void; + onOpen: (file: OpenedFile) => void; }; /** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */ @@ -157,7 +160,7 @@ const RowView = memo(function RowView({ scrollRef, measure, onToggle, - onPreview + onOpen }: RowViewProps) { const [stuckRef, stuck] = useStuck( scrollRef, @@ -199,8 +202,12 @@ const RowView = memo(function RowView({ file={file} token={token} status={row.status} - onPreview={() => - onPreview({ itemKey: row.itemKey, fileId: file.id }) + onOpen={() => + onOpen({ + itemKey: row.itemKey, + fileId: file.id, + kind: file.kind + }) } /> @@ -365,8 +372,15 @@ const VirtualRows = ({ [virtualizer, toggleItem] ); - // 图片预览:灯箱只渲染一个,按分组取图片 - const [preview, setPreview] = useState(null); + // 图片灯箱与视频弹窗各只渲染一个,不放在卡片里: + // 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放 + const [opened, setOpened] = useState(null); + const closeOpened = useCallback(() => setOpened(null), []); + const changePreview = useCallback( + (target: LibraryFileTarget | null) => + setOpened(target && { ...target, kind: "image" }), + [] + ); // 卸载时(如列表被空状态替换)补存一次快照 useEffect( @@ -420,7 +434,7 @@ const VirtualRows = ({ scrollRef={scrollRef} measure={virtualizer.measureElement} onToggle={onToggleHeader} - onPreview={setPreview} + onOpen={setOpened} /> ); const renderPiece = (piece: RowPiece) => @@ -457,8 +471,14 @@ const VirtualRows = ({ + ); diff --git a/src/app/(app)/components/library-rows.ts b/src/app/(app)/components/library-rows.ts index d8380a6..17ba962 100644 --- a/src/app/(app)/components/library-rows.ts +++ b/src/app/(app)/components/library-rows.ts @@ -43,6 +43,13 @@ export type LibraryRow = export const getItemKey = (item: LibraryItemView) => `${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 只依赖资源与分块序号、不依赖列数, * 保证刷新、插入新资源、窗口缩放时锚点行仍可定位。 diff --git a/src/app/(app)/components/library-video-dialog.tsx b/src/app/(app)/components/library-video-dialog.tsx new file mode 100644 index 0000000..5b6e474 --- /dev/null +++ b/src/app/(app)/components/library-video-dialog.tsx @@ -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: () => + } +); + +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 ( + { + if (!open) onClose(); + }} + > + {file && streamUrl && ( + + {/* 右侧留出关闭按钮的位置 */} + + + {file.displayName} + + + {file.ext || "file"} ·{" "} + {formatBytes(file.size)} + {hlsUrl && " · TorBox 转码"} + + + + + + {/* 交给外部播放器后关闭弹窗,避免两边同时播放 */} + + + + )} + + ); +}; diff --git a/src/app/api/hls/[type]/[id]/[fileId]/route.ts b/src/app/api/hls/[type]/[id]/[fileId]/route.ts new file mode 100644 index 0000000..14118b9 --- /dev/null +++ b/src/app/api/hls/[type]/[id]/[fileId]/route.ts @@ -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" + ); + } +} diff --git a/src/components/dot-field.tsx b/src/components/dot-field.tsx index 83c7a1f..f22e0c9 100644 --- a/src/components/dot-field.tsx +++ b/src/components/dot-field.tsx @@ -4,13 +4,24 @@ import { useEffect, useImperativeHandle, useRef } from "react"; import { useReducedMotion } from "motion/react"; -import { DOT_FIELD, RIPPLES, type RippleName, type RippleTone } from "@/constants/motion"; -import { createDotField, type DotField as DotFieldEngine } from "@/lib/dot-field"; +import { + 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"; export type DotFieldHandle = { /** 从元素中心或画布坐标扩散一次涟漪;动效关闭时立即 resolve */ - ripple: (origin: Element | { x: number; y: number }, name: RippleName) => Promise; + ripple: ( + origin: Element | { x: number; y: number }, + name: RippleName + ) => Promise; }; type DotFieldProps = { @@ -26,7 +37,10 @@ const TONE_VARS: Record = { const readColors = (element: HTMLElement) => { const style = getComputedStyle(element); 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; }; @@ -43,11 +57,15 @@ export const DotField = ({ ref, className }: DotFieldProps) => { const canvas = canvasRef.current; 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; 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); @@ -63,7 +81,10 @@ export const DotField = ({ ref, className }: DotFieldProps) => { return () => { observer.disconnect(); window.removeEventListener("pointermove", onPointerMove); - document.documentElement.removeEventListener("pointerleave", onPointerLeave); + document.documentElement.removeEventListener( + "pointerleave", + onPointerLeave + ); engine.destroy(); engineRef.current = null; }; @@ -78,11 +99,20 @@ export const DotField = ({ ref, className }: DotFieldProps) => { let point = origin as { x: number; y: number }; if (origin instanceof Element) { 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