diff --git a/docs/design-system.md b/docs/design-system.md index 64e4f26..9a6e3b8 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -53,7 +53,7 @@ TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页 - `--radius: 0.375rem`(6px,与官网一致),组件按 shadcn 派生规则取 `rounded-lg` / `rounded-xl`。 - 间距只用 `gap-*`,不用 `space-x/y-*`;常用节奏:控件间 `gap-2`,区块内 `gap-3`,区块间 `gap-8`。 -- 内容最大宽度 `sm:max-w-5/6`,水平内边距 `px-4 md:px-6`;header、工具栏的内层容器与内容区对齐。 +- 内容最大宽度 `sm:max-w-[1920px]`,水平内边距 `px-4 md:px-6`;header、工具栏的内层容器与内容区对齐。 - 等宽等高用 `size-*`,单行截断用 `truncate`,多行截断用 `line-clamp-*`。 ## 5. 布局规则 @@ -106,7 +106,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | 下拉操作 | `DropdownMenu` | | 侧滑面板 | `Sheet`(右侧滑出、高度撑满、右上角关闭):手机顶栏菜单 | | 图标按钮说明 | `Tooltip` | -| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在全屏 `Dialog` 中播放(类似视频网站的播放页),`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),信息行注明「TorBox 转码」;`onClose` 在画面左上角放关闭按钮(皮肤的 `topStart` 插槽,样式与显隐同右上角按钮组);标题右侧提供下载与「PotPlayer 播放」(手机宽度 `< md` 隐藏 PotPlayer);播放错误说明替换为中文并提示换用 PotPlayer | +| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放(类似视频网站的播放页):手机全屏,`sm` 起铺满顶栏以下、顶栏保持可见,内容限宽 1920px 居中;非模态(`modal={false}`),看视频时顶栏照常可用,被盖住的 `
` 设为 `inert`;不渲染变暗模糊的遮罩(`showOverlay={false}`),点击弹窗外不关闭(`disablePointerDismissal`),`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),信息行注明「TorBox 转码」;`onClose` 在画面左上角放关闭按钮(皮肤的 `topStart` 插槽,样式与显隐同右上角按钮组);标题右侧提供下载与「PotPlayer 播放」(手机宽度 `< md` 隐藏 PotPlayer);播放错误说明替换为中文并提示换用 PotPlayer | | 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | | 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) | | 错误提示 | `Alert variant="destructive"` | @@ -204,7 +204,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 | | 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 | | 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` | -| 视频播放 | 点击视频封面或「播放」全屏打开并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);视频在顶部(手机贴边),下方依次是标题、信息行与操作;视频宽度上限按视口高度换算(`(100dvh - 9rem) × 16/9`),矮屏时等比缩小居中;关闭按钮在视频左上角、随控制栏显隐,也可 Esc 或返回键 / 返回手势关闭(打开时压入一条历史记录,用其他方式关闭时撤回,不留多余记录);「PotPlayer 播放」交给外部播放器后关闭 | `LibraryVideoDialog` + `VideoPlayer` + `useCloseOnBack`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 | +| 视频播放 | 点击视频封面或「播放」全屏打开并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);视频在顶部(手机贴边),下方依次是标题、信息行与操作;视频宽度上限按视口高度换算(`(100dvh - 9rem) × 16/9`,`sm` 起扣掉顶栏用 `14rem`),矮屏时等比缩小居中;关闭按钮在视频左上角、随控制栏显隐,也可点顶栏品牌标识(看视频时变成「返回资源库」按钮,经 `useVideoDialogStore` 登记的关闭方法)、Esc 或返回键 / 返回手势关闭(打开时压入一条历史记录,用其他方式关闭时撤回,不留多余记录);「PotPlayer 播放」交给外部播放器后关闭 | `LibraryVideoDialog` + `VideoPlayer` + `useCloseOnBack`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 | | 视频播放列表 | 同一资源的视频超过一个时始终显示,无边框,标题行只有「播放列表 3 / 12」:宽屏(`≥ lg`)在右侧占满高度,窄屏在信息行下方占满剩余高度(最低 12rem,更矮时整块滚动),内容多时只滚动列表;点击切换,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 | | 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 | | 手机顶栏菜单 | 宽度 `< md` 时顶栏右侧的播放列表、退出登录与工具栏的来源 / 下载状态 / 类型三组筛选收进右上角汉堡按钮;点击后菜单从右侧滑出(高度撑满,右上角关闭):筛选按等宽网格排列并带「重置」(只清这三项,保留关键词),下面是 M3U 的「复制 M3U 地址」(手机上没有 PotPlayer,不提供「用 PotPlayer 打开」),退出登录固定在底部;三项中任一项不是「全部」时汉堡图标右上角弹出翠绿提示点,读屏名为「菜单(已启用筛选)」 | `MobileMenu`(`Sheet`)+ `LibraryFilters layout="stacked"`(与工具栏共用);提示点用单格 grid 叠层 + Motion `scale`,参数见 `INDICATOR_DOT`;来源计数只在菜单打开时计算 | diff --git a/src/app/(app)/components/app-header.tsx b/src/app/(app)/components/app-header.tsx index eabfea5..d81c8fe 100644 --- a/src/app/(app)/components/app-header.tsx +++ b/src/app/(app)/components/app-header.tsx @@ -1,9 +1,9 @@ // 应用顶栏 -import { BrandMark } from "@/components/brand-mark"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { AddMagnetButton } from "./add-magnet-button"; +import { HeaderBrand } from "./header-brand"; import { LogoutButton } from "./logout-button"; import { MobileMenu } from "./mobile-menu"; import { PlaylistMenu } from "./playlist-menu"; @@ -19,8 +19,8 @@ type AppHeaderProps = { export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
-
- +
+ {/* 手机宽度下放不下,演示模式只在登录页提示 */} {mock && ( { + const closeVideo = useVideoDialogStore((state) => state.close); + + if (!closeVideo) return ; + return ( + + + } + > + + + 返回资源库 + + ); +}; diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx index b20cf8f..83514f9 100644 --- a/src/app/(app)/components/library-list.tsx +++ b/src/app/(app)/components/library-list.tsx @@ -229,7 +229,10 @@ export const LibraryList = (props: LibraryListProps) => { const [contentRef, width] = useElementWidth(); return ( -
+
{/* 先拿到容器宽度再创建虚拟器,保证首帧列数与快照恢复一致 */} {width > 0 && }
diff --git a/src/app/(app)/components/library-toolbar.tsx b/src/app/(app)/components/library-toolbar.tsx index 3ad29dc..9847016 100644 --- a/src/app/(app)/components/library-toolbar.tsx +++ b/src/app/(app)/components/library-toolbar.tsx @@ -198,7 +198,7 @@ export const LibraryToolbar = ({ return (
-
+
diff --git a/src/app/(app)/components/library-video-dialog.tsx b/src/app/(app)/components/library-video-dialog.tsx index 319812f..5cbe935 100644 --- a/src/app/(app)/components/library-video-dialog.tsx +++ b/src/app/(app)/components/library-video-dialog.tsx @@ -1,4 +1,4 @@ -// 资源库视频播放:全屏打开,视频在上、信息在下,播放列表手机在下方、宽屏在右侧 +// 资源库视频播放:手机全屏、宽屏铺满顶栏以下,视频在上、信息在下,播放列表手机在下方、宽屏在右侧 "use client"; import { useEffect, useState } from "react"; @@ -23,6 +23,7 @@ import { useCloseOnBack } from "@/hooks/use-close-on-back"; import type { LibraryItemView } from "@/hooks/use-library"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useOrigin } from "@/hooks/use-origin"; +import { useVideoDialogStore } from "@/stores/video-dialog-store"; import { formatBytes } from "@/utils/format"; import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media"; @@ -30,11 +31,11 @@ import { findLibraryItem, type LibraryFileTarget } from "./library-rows"; import { VideoPlaylist } from "./video-playlist"; /** - * 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置。 - * 手机宽度下贴边、去掉圆角 + * 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置 + * (sm 起还要让出顶栏与上边距)。手机宽度下贴边、去掉圆角 */ const PLAYER_CLASS = - "mx-auto aspect-video w-full max-w-[calc((100dvh-9rem)*16/9)] max-md:rounded-none"; + "mx-auto aspect-video w-full max-w-[calc((100dvh-9rem)*16/9)] sm:max-w-[calc((100dvh-14rem)*16/9)] max-md:rounded-none"; /** 播放器较大,打开时才加载 */ const VideoPlayer = dynamic(importVideoPlayer, { @@ -98,6 +99,23 @@ export const LibraryVideoDialog = ({ // 手机返回键 / 返回手势先关闭播放,不离开页面 useCloseOnBack(open, onClose); + // 非模态弹窗不会让页面其余部分失效:把被它盖住的内容区(顶栏以下的
)设为 inert, + // Tab 与「/」聚焦搜索等快捷键不会落到看不见的资源列表上;顶栏不受影响 + useEffect(() => { + if (!open) return; + const main = document.querySelector("main"); + main?.setAttribute("inert", ""); + return () => main?.removeAttribute("inert"); + }, [open]); + + // 打开期间登记关闭方法:点顶栏品牌标识也能关闭,回到资源库 + const setClose = useVideoDialogStore((state) => state.setClose); + useEffect(() => { + if (!open) return; + setClose(onClose); + return () => setClose(null); + }, [open, onClose, setClose]); + // 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎 const ready = streamUrl !== null; const needsHls = hlsUrl !== null; @@ -111,89 +129,103 @@ export const LibraryVideoDialog = ({ onOpenChange={(next) => { if (!next) onClose(); }} + // 非模态:看视频时顶栏照常可用;点击弹窗外或焦点移出都不关闭, + // 关闭按钮在视频左上角,也可 Esc 或返回键关闭 + modal={false} + disablePointerDismissal > {file && streamUrl && ( - {/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */} -
-
- selectFile(nextFile))} - onClose={onClose} - errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。" - className={PLAYER_CLASS} - /> -
- {/* 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动 */} -
-
- - - {file.displayName} - - - {file.ext || "file"} ·{" "} - {formatBytes(file.size)} - {hlsUrl && " · TorBox 转码"} - - -
- - {/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */} - -
+ {/* 与顶栏、资源列表同样限宽 1920px 居中 */} +
+ {/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */} +
+
+ selectFile(nextFile))} + onClose={onClose} + errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。" + className={PLAYER_CLASS} + />
- {hasPlaylist && !wide && ( + {/* + 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动。 + md 起向两侧外扩 1rem 再补回内边距:文字与视频左缘对齐,列表项底色外扩时不被裁切 + */} +
+
+ + + {file.displayName} + + + {file.ext || "file"} ·{" "} + {formatBytes(file.size)} + {hlsUrl && " · TorBox 转码"} + + +
+ + {/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */} + +
+
+ {hasPlaylist && !wide && ( + + )} +
+
+ {/* 宽屏:播放列表在右侧,占满高度、单独滚动 */} + {hasPlaylist && wide && ( +
- )} -
+
+ )}
- {/* 宽屏:播放列表在右侧,占满高度、单独滚动 */} - {hasPlaylist && wide && ( -
- -
- )} )} diff --git a/src/app/(app)/components/library-view.tsx b/src/app/(app)/components/library-view.tsx index f309ddd..8835d28 100644 --- a/src/app/(app)/components/library-view.tsx +++ b/src/app/(app)/components/library-view.tsx @@ -135,7 +135,7 @@ export const LibraryView = ({ token }: { token: string }) => { className="min-h-0 flex-1 scroll-pt-14 overflow-y-auto" > {content ? ( -
+
{content}
) : ( diff --git a/src/app/(app)/components/video-playlist.tsx b/src/app/(app)/components/video-playlist.tsx index 270cecd..5bdfe03 100644 --- a/src/app/(app)/components/video-playlist.tsx +++ b/src/app/(app)/components/video-playlist.tsx @@ -38,7 +38,7 @@ export const VideoPlaylist = ({ className={cn("flex min-h-0 flex-col gap-1", className)} >
-

播放列表

+

播放列表

{index + 1} / {files.length} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index fe0ef56..37bcce4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -43,13 +43,16 @@ function DialogContent({ className, children, showCloseButton = true, + showOverlay = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean + /** 关闭时不渲染变暗、模糊的遮罩;模态弹窗仍会拦截弹窗外的点击 */ + showOverlay?: boolean }) { return ( - + {showOverlay && } void) => { useEffect(() => { if (!open) return; - window.history.pushState({ [MARKER]: true }, ""); + // 每次打开用新的 id:刷新页面后 history.state 会保留,旧记录上的标记不能当成本次的 + const id = `${Date.now()}-${++sequence}`; + window.history.pushState({ [MARKER]: id }, ""); let popped = false; const onPopState = (event: PopStateEvent) => { - // 仍停在本 hook 压入的记录上(开发模式 effect 重跑时撤回的上一条),不处理 - if (event.state?.[MARKER]) return; + // 前进键回到本次压入的记录,不处理;回到其他任何记录都视为返回 + if (event.state?.[MARKER] === id) return; popped = true; close(); }; window.addEventListener("popstate", onPopState); return () => { window.removeEventListener("popstate", onPopState); - if (!popped && window.history.state?.[MARKER]) window.history.back(); + if (popped) return; + // 延后一拍再撤回:开发模式下 effect 会立即重跑并压入新记录,此时当前记录已不是本次的,不再撤回 + queueMicrotask(() => { + if (window.history.state?.[MARKER] === id) window.history.back(); + }); }; }, [open]); }; diff --git a/src/stores/video-dialog-store.ts b/src/stores/video-dialog-store.ts new file mode 100644 index 0000000..755bd48 --- /dev/null +++ b/src/stores/video-dialog-store.ts @@ -0,0 +1,14 @@ +// 视频弹窗与顶栏共享的状态 store +import { create } from "zustand"; + +type VideoDialogState = { + /** 打开中的视频弹窗的关闭方法,未打开时为 null;顶栏品牌标识据此变成「返回资源库」按钮 */ + close: (() => void) | null; + setClose: (close: (() => void) | null) => void; +}; + +/** 视频弹窗打开时登记关闭方法,关闭后清除 */ +export const useVideoDialogStore = create()((set) => ({ + close: null, + setClose: (close) => set({ close }) +}));