From e8f62bc89a47de7cf172bbe25cbce065f204763c Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Tue, 6 Oct 2026 09:21:03 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B8=83=E5=B1=80=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design-system.md | 10 +- src/app/(app)/components/app-header.tsx | 11 +- src/app/(app)/components/library-filters.tsx | 160 ++++++++++++++ src/app/(app)/components/library-toolbar.tsx | 96 +-------- .../(app)/components/library-video-dialog.tsx | 204 ++++++++---------- src/app/(app)/components/logout-button.tsx | 16 +- src/app/(app)/components/mobile-menu.tsx | 138 ++++++++++++ src/app/(app)/components/playlist-menu.tsx | 23 +- src/app/(app)/components/search-dialog.tsx | 11 +- .../(app)/components/search-result-list.tsx | 12 ++ .../components/search-result-toolbar.tsx | 35 ++- src/app/(app)/components/video-playlist.tsx | 70 +----- src/components/ui/alert.tsx | 2 +- src/components/ui/badge.tsx | 2 +- src/components/ui/button.tsx | 2 +- src/components/ui/card.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 2 +- src/components/ui/empty.tsx | 2 +- src/components/ui/field.tsx | 2 +- src/components/ui/input-group.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/kbd.tsx | 2 +- src/components/ui/label.tsx | 2 +- src/components/ui/progress.tsx | 2 +- src/components/ui/separator.tsx | 2 +- src/components/ui/sheet.tsx | 138 ++++++++++++ src/components/ui/skeleton.tsx | 2 +- src/components/ui/spinner.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/components/ui/toggle-group.tsx | 2 +- src/components/ui/toggle.tsx | 2 +- src/components/ui/tooltip.tsx | 2 +- src/components/video-player.tsx | 24 ++- src/components/videojs/video/skin.tsx | 31 ++- src/constants/motion.ts | 8 +- src/hooks/use-close-on-back.ts | 31 +++ src/hooks/use-playlist.ts | 17 ++ src/hooks/use-search-results.ts | 19 +- src/server/mock-search.ts | 11 + src/server/search.ts | 40 +++- src/server/torbox.ts | 27 +++ src/stores/library-filter-store.ts | 14 +- src/stores/search-store.ts | 19 +- src/stores/search-view-store.ts | 8 + src/types/search.ts | 2 + src/utils/magnet.ts | 5 + 47 files changed, 877 insertions(+), 343 deletions(-) create mode 100644 src/app/(app)/components/library-filters.tsx create mode 100644 src/app/(app)/components/mobile-menu.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/hooks/use-close-on-back.ts create mode 100644 src/hooks/use-playlist.ts diff --git a/docs/design-system.md b/docs/design-system.md index 7016cc3..64e4f26 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -76,7 +76,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx - **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。 - 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。 - shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。 -- **浮层层级**:Dialog、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。 +- **浮层层级**:Dialog、Sheet、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。 ### 长列表(虚拟滚动) @@ -104,8 +104,9 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | 文件卡片 | `Card size="sm"` + `CardContent` + `CardFooter`;底栏左侧一个主操作(视频「播放」、图片「预览」,其他文件没有),右侧图标操作(视频多一个 PotPlayer,所有文件都有下载、复制直链),名称放在 `Tooltip` 与 `aria-label`;卡片是 `@container/card`,窄于 186px(手机两列)时主操作只显示图标 | | 标签 | `Badge`(来源:`variant="outline"` + `SourceDot`) | | 下拉操作 | `DropdownMenu` | +| 侧滑面板 | `Sheet`(右侧滑出、高度撑满、右上角关闭):手机顶栏菜单 | | 图标按钮说明 | `Tooltip` | -| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放,`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),标题下注明「TorBox 转码」;底栏提供下载与「PotPlayer 播放」;播放错误说明替换为中文并提示换用 PotPlayer | +| 视频播放 | `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 | | 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | | 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) | | 错误提示 | `Alert variant="destructive"` | @@ -203,8 +204,9 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx | 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 | | 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 | | 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` | -| 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 | -| 视频播放列表 | 弹窗里同一资源的视频超过一个时,底栏左侧出现「播放列表 3 / 12」开关,列表默认收起(收起时播放器占满宽度),展开状态在弹窗关闭后保留;展开时列表从右侧滑入、播放器随之收窄,收起时滑出(窄屏改为在播放器下方展开 / 收起),参数见 `PLAYLIST_SLIDE`:宽屏在播放器右侧、高度跟随播放器,窄屏在下方限高;点击切换、上一个 / 下一个按钮,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 | +| 视频播放 | 点击视频封面或「播放」全屏打开并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);视频在顶部(手机贴边),下方依次是标题、信息行与操作;视频宽度上限按视口高度换算(`(100dvh - 9rem) × 16/9`),矮屏时等比缩小居中;关闭按钮在视频左上角、随控制栏显隐,也可 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`;来源计数只在菜单打开时计算 | | 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` | diff --git a/src/app/(app)/components/app-header.tsx b/src/app/(app)/components/app-header.tsx index 17e6f38..eabfea5 100644 --- a/src/app/(app)/components/app-header.tsx +++ b/src/app/(app)/components/app-header.tsx @@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator"; import { AddMagnetButton } from "./add-magnet-button"; import { LogoutButton } from "./logout-button"; +import { MobileMenu } from "./mobile-menu"; import { PlaylistMenu } from "./playlist-menu"; import { SearchButton } from "./search-button"; @@ -33,9 +34,13 @@ export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
{searchEnabled && } - - - + {/* 手机宽度下播放列表与退出登录收进右上角菜单 */} +
+ + + +
+
diff --git a/src/app/(app)/components/library-filters.tsx b/src/app/(app)/components/library-filters.tsx new file mode 100644 index 0000000..b5f06bc --- /dev/null +++ b/src/app/(app)/components/library-filters.tsx @@ -0,0 +1,160 @@ +// 资源列表的三个筛选项(来源、下载状态、文件类型):宽屏内联在工具栏,手机放进顶栏菜单 +"use client"; + +import { AnimatedNumber } from "@/components/animated-number"; +import { FieldLegend, FieldSet } from "@/components/ui/field"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { SOURCES, SOURCE_LABELS } from "@/constants/torbox"; +import { cn } from "@/lib/utils"; +import { + useLibraryFilterStore, + type KindFilter, + type SourceFilter, + type StatusFilter +} from "@/stores/library-filter-store"; +import { formatCount } from "@/utils/format"; + +import { SourceDot } from "./source-dot"; + +// 数字滚动过程中的格式化:计数取整 +const formatInteger = (value: number) => formatCount(Math.round(value)); + +const KIND_OPTIONS: { value: KindFilter; label: string }[] = [ + { value: "all", label: "全部" }, + { value: "video", label: "视频" }, + { value: "image", label: "图片" }, + { value: "other", label: "其他" } +]; + +const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: "all", label: "全部" }, + { value: "ready", label: "已完成" }, + { value: "downloading", label: "下载中" }, + { value: "unavailable", label: "已失效" }, + { value: "airlocked", label: "AirLock" } +]; + +type FilterLayout = "inline" | "stacked"; + +type FilterGroupProps = { + layout: FilterLayout; + /** 筛选维度名,如「来源」;分段控件的读屏名为「按来源筛选」 */ + title: string; + /** stacked 时的等宽列数(完整类名,供 Tailwind 扫描) */ + columns: string; + value: T; + onChange: (value: T) => void; + children: React.ReactNode; +}; + +/** inline:分段控件;stacked:带标题的等宽网格,窄面板里按列换行 */ +const FilterGroup = ({ + layout, + title, + columns, + value, + onChange, + children +}: FilterGroupProps) => { + const stacked = layout === "stacked"; + const group = ( + // 单选:再次点击已选项时保持当前值,不允许清空 + next[0] && onChange(next[0] as T)} + > + {children} + + ); + + if (!stacked) return group; + return ( +
+ {title} + {group} +
+ ); +}; + +type LibraryFiltersProps = { + /** 各来源在关键词、类型、状态筛选后的资源数 */ + sourceCounts: Record; + layout?: FilterLayout; +}; + +export const LibraryFilters = ({ + sourceCounts, + layout = "inline" +}: LibraryFiltersProps) => { + const source = useLibraryFilterStore((state) => state.source); + const kind = useLibraryFilterStore((state) => state.kind); + const status = useLibraryFilterStore((state) => state.status); + const setSource = useLibraryFilterStore((state) => state.setSource); + const setKind = useLibraryFilterStore((state) => state.setKind); + const setStatus = useLibraryFilterStore((state) => state.setStatus); + + return ( + <> + + + 全部 + + + {SOURCES.map(({ type }) => ( + + + {SOURCE_LABELS[type]} + + + ))} + + + + {STATUS_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + {KIND_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + ); +}; diff --git a/src/app/(app)/components/library-toolbar.tsx b/src/app/(app)/components/library-toolbar.tsx index ff336bc..3ad29dc 100644 --- a/src/app/(app)/components/library-toolbar.tsx +++ b/src/app/(app)/components/library-toolbar.tsx @@ -34,46 +34,27 @@ import { } from "@/components/ui/input-group"; import { Kbd } from "@/components/ui/kbd"; import { Spinner } from "@/components/ui/spinner"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { LIBRARY_SORT_OPTIONS } from "@/constants/library"; -import { SOURCES, SOURCE_LABELS } from "@/constants/torbox"; import { useHotkey } from "@/hooks/use-hotkey"; import { useLibraryFilterStore, - type KindFilter, type LibrarySort, - type SourceFilter, - type StatusFilter + type SourceFilter } from "@/stores/library-filter-store"; import { useLibraryViewStore } from "@/stores/library-view-store"; import type { SortOrder } from "@/types/search"; import { formatBytes, formatCount } from "@/utils/format"; -import { SourceDot } from "./source-dot"; +import { LibraryFilters } from "./library-filters"; // 数字滚动过程中的格式化:计数取整,大小按 1024 进制 const formatInteger = (value: number) => formatCount(Math.round(value)); -const KIND_OPTIONS: { value: KindFilter; label: string }[] = [ - { value: "all", label: "全部" }, - { value: "video", label: "视频" }, - { value: "image", label: "图片" }, - { value: "other", label: "其他" } -]; - -const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: "all", label: "全部" }, - { value: "ready", label: "已完成" }, - { value: "downloading", label: "下载中" }, - { value: "unavailable", label: "已失效" }, - { value: "airlocked", label: "AirLock" } -]; - const ORDER_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "asc", label: "升序" }, { value: "desc", label: "降序" } @@ -206,13 +187,7 @@ export const LibraryToolbar = ({ onRefresh }: LibraryToolbarProps) => { const keyword = useLibraryFilterStore((state) => state.keyword); - const source = useLibraryFilterStore((state) => state.source); - const kind = useLibraryFilterStore((state) => state.kind); const setKeyword = useLibraryFilterStore((state) => state.setKeyword); - const setSource = useLibraryFilterStore((state) => state.setSource); - const setKind = useLibraryFilterStore((state) => state.setKind); - const status = useLibraryFilterStore((state) => state.status); - const setStatus = useLibraryFilterStore((state) => state.setStatus); // "/" 聚焦搜索;搜索框内 Esc 先清空,已为空则失焦 const searchRef = useRef(null); @@ -255,69 +230,10 @@ export const LibraryToolbar = ({ - {/* 单选:再次点击已选项时保持当前值,不允许清空 */} - - value[0] && setSource(value[0] as SourceFilter) - } - > - - 全部 - - - {SOURCES.map(({ type }) => ( - - - {SOURCE_LABELS[type]} - - - ))} - - - - value[0] && setStatus(value[0] as StatusFilter) - } - > - {STATUS_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - value[0] && setKind(value[0] as KindFilter)} - > - {KIND_OPTIONS.map((option) => ( - - {option.label} - - ))} - + {/* 手机宽度下三个筛选项收进顶栏菜单(MobileMenu) */} +
+ +
@@ -63,11 +64,8 @@ export const LibraryVideoDialog = ({ }: LibraryVideoDialogProps) => { const origin = useOrigin(); const wide = useMediaQuery("(min-width: 1024px)"); - const reduced = useReducedMotion(); // 关闭动画期间 target 已清空,继续显示刚才的视频 const [shown, setShown] = useState(target); - // 播放列表默认收起,展开状态在弹窗关闭后保留 - const [playlistOpen, setPlaylistOpen] = useState(false); if (target && target !== shown) setShown(target); const item = shown ? findLibraryItem(items, shown.itemKey) : undefined; @@ -81,13 +79,6 @@ export const LibraryVideoDialog = ({ ? item.files.filter((entry) => entry.kind === "video") : []; const hasPlaylist = playlist.length > 1; - const showPlaylist = hasPlaylist && playlistOpen; - // 宽屏从右侧滑入(宽度),窄屏从播放器下方展开(高度) - const collapsed = wide ? { width: 0, opacity: 0 } : { height: 0, opacity: 0 }; - const expanded = wide - ? { width: PLAYLIST_SLIDE.width, opacity: 1 } - : { height: "auto", opacity: 1 }; - const position = playlist.findIndex((entry) => entry.id === file?.id) + 1; const selectFile = (next: { id: number }) => shown && onTargetChange({ itemKey: shown.itemKey, fileId: next.id }); const nextFile = file @@ -102,6 +93,10 @@ export const LibraryVideoDialog = ({ item && file && origin && !BROWSER_VIDEO_EXT.has(file.ext) ? buildHlsUrl(origin, item.source, item.id, file.id, token) : null; + const open = target !== null && streamUrl !== null; + + // 手机返回键 / 返回手势先关闭播放,不离开页面 + useCloseOnBack(open, onClose); // 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎 const ready = streamUrl !== null; @@ -112,104 +107,93 @@ export const LibraryVideoDialog = ({ return ( { - if (!open) onClose(); + open={open} + onOpenChange={(next) => { + if (!next) onClose(); }} > {file && streamUrl && ( - {/* 右侧留出关闭按钮的位置 */} - - - {file.displayName} - - - {file.ext || "file"} ·{" "} - {formatBytes(file.size)} - {hlsUrl && " · TorBox 转码"} - - - {/* 宽屏时列表在播放器右侧,高度跟随播放器;窄屏放在下方并限高 */} -
- selectFile(nextFile))} - errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。" - className={PLAYER_CLASS} - /> - - {showPlaylist && ( - - {/* 宽屏时固定宽度,面板随外层展开从右侧「移入」;h-0 + min-h-full 让列表高度跟随播放器而不撑高这一行 */} -
- -
-
+ {/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */} +
+
+ selectFile(nextFile))} + onClose={onClose} + errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。" + className={PLAYER_CLASS} + /> +
+ {/* 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动 */} +
+
+ + + {file.displayName} + + + {file.ext || "file"} ·{" "} + {formatBytes(file.size)} + {hlsUrl && " · TorBox 转码"} + + +
+ + {/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */} + +
+
+ {hasPlaylist && !wide && ( + )} - +
- - {hasPlaylist && ( - - )} - - {/* 交给外部播放器后关闭弹窗,避免两边同时播放 */} - - + {/* 宽屏:播放列表在右侧,占满高度、单独滚动 */} + {hasPlaylist && wide && ( +
+ +
+ )} )}
diff --git a/src/app/(app)/components/logout-button.tsx b/src/app/(app)/components/logout-button.tsx index 0041389..4005ca5 100644 --- a/src/app/(app)/components/logout-button.tsx +++ b/src/app/(app)/components/logout-button.tsx @@ -12,9 +12,23 @@ import { } from "@/components/ui/tooltip"; import { useLogout } from "@/hooks/use-auth"; -export const LogoutButton = () => { +/** 默认是顶栏里的图标按钮;labeled 时带文字,用于手机菜单底部 */ +export const LogoutButton = ({ labeled = false }: { labeled?: boolean }) => { const { mutate, isPending } = useLogout(); + if (labeled) { + return ( + + ); + } + return ( { + const { sourceCounts } = useFilteredLibrary(); + const filtered = useLibraryFilterStore(selectFacetFiltered); + const resetFacets = useLibraryFilterStore((state) => state.resetFacets); + + return ( +
+
+

+ 筛选 +

+ +
+ + + +
+ ); +}; + +const MenuPlaylist = ({ token }: { token: string }) => { + const playlist = usePlaylist(token); + + return ( +
+

+ 播放列表 + + 全部资源 · M3U + +

+ {/* 手机上没有 PotPlayer,只提供复制地址给其他播放器 */} + +
+ ); +}; + +/** 汉堡按钮:来源、下载状态、类型任一项有筛选时,右上角亮起翠绿提示点 */ +const MenuTrigger = () => { + const filtered = useLibraryFilterStore(selectFacetFiltered); + + return ( + + } + > + {/* 单格 grid 叠层:提示点叠在图标右上角 */} + + + + {filtered && ( + + + + ); +}; + +export const MobileMenu = ({ token }: { token: string }) => ( + + + + + 菜单 + +
+ + + +
+ + + +
+
+); diff --git a/src/app/(app)/components/playlist-menu.tsx b/src/app/(app)/components/playlist-menu.tsx index f8727c7..6f16569 100644 --- a/src/app/(app)/components/playlist-menu.tsx +++ b/src/app/(app)/components/playlist-menu.tsx @@ -1,4 +1,4 @@ -// 播放列表菜单 +// 播放列表菜单(宽屏顶栏;手机宽度下同样的操作在 MobileMenu 里) "use client"; import { @@ -17,36 +17,27 @@ import { DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { useCopy } from "@/hooks/use-copy"; -import { useOrigin } from "@/hooks/use-origin"; -import { buildPlaylistUrl, toPotPlayerUrl } from "@/utils/media"; +import { usePlaylist } from "@/hooks/use-playlist"; export const PlaylistMenu = ({ token }: { token: string }) => { - const origin = useOrigin(); - const copy = useCopy(); - const playlistUrl = origin ? buildPlaylistUrl(origin, token) : ""; + const playlist = usePlaylist(token); return ( } + render={ diff --git a/src/app/(app)/components/search-result-list.tsx b/src/app/(app)/components/search-result-list.tsx index b788c80..0ba0423 100644 --- a/src/app/(app)/components/search-result-list.tsx +++ b/src/app/(app)/components/search-result-list.tsx @@ -9,6 +9,7 @@ import { CloudDownloadIcon, CopyIcon, ExternalLinkIcon, + ZapIcon, type LucideIcon } from "lucide-react"; @@ -157,6 +158,7 @@ const ResultRow = memo(function ResultRow({ now: number; }) { const { publishedAt, size, seeders, leechers } = result; + const cached = useSearchStore((state) => state.cachedIds[result.id] === true); return (
@@ -182,6 +184,16 @@ const ResultRow = memo(function ResultRow({ {leechers != null && ( )} + {cached && ( + + + 已缓存 + + )}
{/* 窄屏换到下一行,靠右 */}
diff --git a/src/app/(app)/components/search-result-toolbar.tsx b/src/app/(app)/components/search-result-toolbar.tsx index d63e4bb..50b2a2c 100644 --- a/src/app/(app)/components/search-result-toolbar.tsx +++ b/src/app/(app)/components/search-result-toolbar.tsx @@ -6,10 +6,12 @@ import { ArrowDownUpIcon, ArrowUpIcon, ListFilterIcon, - XIcon + XIcon, + ZapIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Toggle } from "@/components/ui/toggle"; import { DropdownMenu, DropdownMenuContent, @@ -121,6 +123,8 @@ export const SearchResultToolbar = ({ }: SearchResultToolbarProps) => { const keyword = useSearchViewStore((state) => state.keyword); const setKeyword = useSearchViewStore((state) => state.setKeyword); + const cachedOnly = useSearchViewStore((state) => state.cachedOnly); + const setCachedOnly = useSearchViewStore((state) => state.setCachedOnly); return (
@@ -135,21 +139,34 @@ export const SearchResultToolbar = ({ aria-label="在结果中筛选" autoComplete="off" /> - {keyword && ( + {(keyword || cachedOnly) && ( {shown} / {total} - setKeyword("")} - > - - + {keyword && ( + setKeyword("")} + > + + + )} )} + + + 已缓存 +
); diff --git a/src/app/(app)/components/video-playlist.tsx b/src/app/(app)/components/video-playlist.tsx index 2fa1455..270cecd 100644 --- a/src/app/(app)/components/video-playlist.tsx +++ b/src/app/(app)/components/video-playlist.tsx @@ -2,54 +2,12 @@ "use client"; import { useEffect, useRef } from "react"; -import { - ChevronDownIcon, - ChevronUpIcon, - PlayIcon, - type LucideIcon -} from "lucide-react"; +import { PlayIcon } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger -} from "@/components/ui/tooltip"; import type { LibraryFileView } from "@/hooks/use-library"; import { cn } from "@/lib/utils"; import { formatBytes } from "@/utils/format"; -type StepButtonProps = { - label: string; - icon: LucideIcon; - disabled: boolean; - onClick: () => void; -}; - -const StepButton = ({ - label, - icon: Icon, - disabled, - onClick -}: StepButtonProps) => ( - - - } - > - - - {label} - -); - type VideoPlaylistProps = { files: LibraryFileView[]; /** 正在播放的文件 id */ @@ -66,8 +24,6 @@ export const VideoPlaylist = ({ }: VideoPlaylistProps) => { const listRef = useRef(null); const index = files.findIndex((file) => file.id === currentId); - const previous = files[index - 1]; - const next = files[index + 1]; // 切换后(含自动接下一个)让当前项出现在可视范围内 useEffect(() => { @@ -79,34 +35,18 @@ export const VideoPlaylist = ({ return (
-
+

播放列表

{index + 1} / {files.length} -
- previous && onSelect(previous)} - /> - next && onSelect(next)} - /> -
+ {/* 内容多时只滚动列表;左右外扩让悬停与当前项底色铺出去,文字与标题对齐,p-1 给焦点环留位置 */}
    {files.map((file, position) => { const current = file.id === currentId; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 17ba728..b2a234b 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; const alertVariants = cva( "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 6bc28c3..17b0bfb 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,7 +1,7 @@ import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; const badgeVariants = cva( "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 997e0c7..0e86338 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,6 +1,6 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index cf02f48..14e5ec5 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Card({ className, diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 5cadf3f..fe0ef56 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" -import { cn } from "cn" +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index cafe6ba..f719b39 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Menu as MenuPrimitive } from "@base-ui/react/menu" -import { cn } from "cn" +import { cn } from "@/lib/utils"; import { ChevronRightIcon, CheckIcon } from "lucide-react" function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx index e11ce34..e3465f3 100644 --- a/src/components/ui/empty.tsx +++ b/src/components/ui/empty.tsx @@ -1,5 +1,5 @@ import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Empty({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx index 8ae7f29..f25fddd 100644 --- a/src/components/ui/field.tsx +++ b/src/components/ui/field.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx index 08cd6aa..7b3c09d 100644 --- a/src/components/ui/input-group.tsx +++ b/src/components/ui/input-group.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 7c4ab5a..2e5f147 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx index 2c12278..f2a9806 100644 --- a/src/components/ui/kbd.tsx +++ b/src/components/ui/kbd.tsx @@ -1,4 +1,4 @@ -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { return ( diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index ddfd2e1..bb4d013 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Label({ className, ...props }: React.ComponentProps<"label">) { return ( diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index 7f3ee7d..729c231 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -1,7 +1,7 @@ "use client" import { Progress as ProgressPrimitive } from "@base-ui/react/progress" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Progress({ className, diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index d69c134..771a263 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -1,7 +1,7 @@ "use client" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Separator({ className, diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..112a69a --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" +import { cn } from "@/lib/utils"; + +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index b7f61b3..8897d9e 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx index 4b753d2..01175ea 100644 --- a/src/components/ui/spinner.tsx +++ b/src/components/ui/spinner.tsx @@ -1,4 +1,4 @@ -import { cn } from "cn" +import { cn } from "@/lib/utils"; import { Loader2Icon } from "lucide-react" function Spinner({ className, ...props }: React.ComponentProps<"svg">) { diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 5141de7..5137402 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { return ( diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index fc7757c..3c5f7d1 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group" import { type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; import { toggleVariants } from "@/components/ui/toggle" diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index f38073f..193d42c 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -2,7 +2,7 @@ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { cn } from "@/lib/utils"; const toggleVariants = cva( "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 3f9a713..454c21a 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,7 +1,7 @@ "use client" import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" -import { cn } from "cn" +import { cn } from "@/lib/utils"; function TooltipProvider({ delay = 0, diff --git a/src/components/video-player.tsx b/src/components/video-player.tsx index bb24a3a..a300576 100644 --- a/src/components/video-player.tsx +++ b/src/components/video-player.tsx @@ -3,11 +3,14 @@ import { useEffect, useRef, type ReactNode } from "react"; import dynamic from "next/dynamic"; +import { ChevronLeftIcon } from "lucide-react"; import { I18nProvider } from "@videojs/react"; import zhCN from "@videojs/react/i18n/locales/zh-CN"; import { Video, VideoPlayer as Player } from "@videojs/react/video"; import { importHlsJsVideo } from "@/components/video-player-loader"; +import { Button as MediaButton } from "@/components/videojs/ui/button"; +import { ButtonTooltip } from "@/components/videojs/ui/button-tooltip"; import { VideoSkin } from "@/components/videojs/video/skin"; /** @@ -30,6 +33,8 @@ type VideoPlayerProps = { onEnded?: () => void; /** 替换默认的播放错误说明 */ errorDescription?: ReactNode; + /** 提供时在画面左上角显示关闭按钮,随控制栏显隐 */ + onClose?: () => void; /** 尺寸与比例,如 aspect-video */ className?: string; }; @@ -47,6 +52,7 @@ export const VideoPlayer = ({ autoFocus, onEnded, errorDescription, + onClose, className }: VideoPlayerProps) => { const rootRef = useRef(null); @@ -64,7 +70,23 @@ export const VideoPlayer = ({
    - + + + + + + ) + } + > {hls ? ( >['renderThumbnail']; /** Replaces the default playback error message. */ errorDescription?: ReactNode; + /** 画面左上角的附加操作(如返回 / 关闭),与控制栏一起显隐,样式同右上角按钮组 */ + topStart?: ReactNode; } export function VideoSkin({ @@ -29,11 +32,12 @@ export function VideoSkin({ renderPoster, renderThumbnail, errorDescription, + topStart, ...props }: VideoSkinProps = {}) { return ( - + {/* 有左上角操作时标题向右让出位置 */} + <Title + className={cn( + topStart && + 'ps-[calc(var(--media-control-size)+--spacing(5))] media-md:ps-[calc(var(--media-control-size)+--spacing(5))]' + )} + /> + {topStart && ( + <div + className={cn( + 'absolute top-2 left-2 z-30 flex origin-top items-center gap-px rounded-media-controls p-0.5', + 'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset', + 'transition-[filter,opacity,scale,translate] duration-media-controls-enter ease-out', + 'group-not-data-controls-visible/skin:pointer-events-none', + 'group-not-data-controls-visible/skin:opacity-0', + 'group-not-data-controls-visible/skin:scale-media-hidden', + 'pointer-fine:group-not-data-controls-visible/skin:blur-media-hidden', + 'group-not-data-controls-visible/skin:duration-media-controls', + 'group-not-data-controls-visible/skin:-translate-y-media-hidden-offset' + )} + > + <Tooltip.Provider>{topStart}</Tooltip.Provider> + </div> + )} <DefaultVideoControls renderThumbnail={renderThumbnail} /> diff --git a/src/constants/motion.ts b/src/constants/motion.ts index 90e6118..5d0b56e 100644 --- a/src/constants/motion.ts +++ b/src/constants/motion.ts @@ -149,9 +149,7 @@ export const IMAGE_LOADING = { sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 } } as const; -/** 视频弹窗的播放列表:展开时从右侧滑入,收起时滑出,播放器随之伸缩 */ -export const PLAYLIST_SLIDE = { - /** 宽屏时列表栏宽度(px),含与播放器之间的 12px 间距 */ - width: 284, - transition: { duration: 0.32, ease: [0.22, 1, 0.36, 1] } +/** 手机顶栏菜单按钮上的筛选提示点:出现时弹出,消失时收回 */ +export const INDICATOR_DOT = { + transition: { type: "spring", stiffness: 640, damping: 22 } } as const; diff --git a/src/hooks/use-close-on-back.ts b/src/hooks/use-close-on-back.ts new file mode 100644 index 0000000..6e2965a --- /dev/null +++ b/src/hooks/use-close-on-back.ts @@ -0,0 +1,31 @@ +// 弹层与浏览器返回键联动的 hook +import { useEffect, useEffectEvent } from "react"; + +/** history.state 上的标记,区分本 hook 压入的记录 */ +const MARKER = "__closeOnBack"; + +/** + * 弹层打开时压入一条同地址的历史记录:返回键 / 返回手势先关闭弹层,不离开页面; + * 用关闭按钮、Esc 等其他方式关闭时撤回这条记录,历史栈保持原样。 + * Next.js 会把路由状态并入 pushState 的记录,回到原记录时只是原地恢复。 + */ +export const useCloseOnBack = (open: boolean, onClose: () => void) => { + const close = useEffectEvent(onClose); + + useEffect(() => { + if (!open) return; + window.history.pushState({ [MARKER]: true }, ""); + let popped = false; + const onPopState = (event: PopStateEvent) => { + // 仍停在本 hook 压入的记录上(开发模式 effect 重跑时撤回的上一条),不处理 + if (event.state?.[MARKER]) return; + popped = true; + close(); + }; + window.addEventListener("popstate", onPopState); + return () => { + window.removeEventListener("popstate", onPopState); + if (!popped && window.history.state?.[MARKER]) window.history.back(); + }; + }, [open]); +}; diff --git a/src/hooks/use-playlist.ts b/src/hooks/use-playlist.ts new file mode 100644 index 0000000..ed366f9 --- /dev/null +++ b/src/hooks/use-playlist.ts @@ -0,0 +1,17 @@ +// 全部资源 M3U 播放列表的地址与操作 hook +import { useCopy } from "@/hooks/use-copy"; +import { useOrigin } from "@/hooks/use-origin"; +import { buildPlaylistUrl, toPotPlayerUrl } from "@/utils/media"; + +/** 顶栏「播放列表」菜单与手机菜单共用;服务端渲染阶段地址为空,操作应禁用 */ +export const usePlaylist = (token: string) => { + const origin = useOrigin(); + const copy = useCopy(); + const url = origin ? buildPlaylistUrl(origin, token) : ""; + + return { + url, + openInPotPlayer: () => window.location.assign(toPotPlayerUrl(url)), + copyUrl: () => copy(url, "已复制 M3U 地址") + }; +}; diff --git a/src/hooks/use-search-results.ts b/src/hooks/use-search-results.ts index 1958b0f..f3a24d2 100644 --- a/src/hooks/use-search-results.ts +++ b/src/hooks/use-search-results.ts @@ -1,6 +1,7 @@ // 搜索结果的筛选与排序 import { useDeferredValue, useMemo } from "react"; +import { useSearchStore } from "@/stores/search-store"; import { useSearchViewStore } from "@/stores/search-view-store"; import type { SearchResult, SearchSortKey, SortOrder } from "@/types/search"; import { toTimestamp } from "@/utils/date"; @@ -32,6 +33,8 @@ const getComparator = ( /** 筛选并排序后的结果;terms 是筛选词(小写),供列表高亮命中的部分 */ export const useSearchResults = (results: SearchResult[]) => { const keyword = useSearchViewStore((state) => state.keyword); + const cachedOnly = useSearchViewStore((state) => state.cachedOnly); + const cachedIds = useSearchStore((state) => state.cachedIds); const sortKey = useSearchViewStore((state) => state.sortKey); const order = useSearchViewStore((state) => state.order); // 输入时先让输入框响应,几千条结果的筛选稍后再算 @@ -43,15 +46,17 @@ export const useSearchResults = (results: SearchResult[]) => { ); const items = useMemo(() => { - const matched = terms.length - ? results.filter((result) => { - const title = result.title.toLowerCase(); - return terms.every((term) => title.includes(term)); - }) - : [...results]; + const matched = + terms.length || cachedOnly + ? results.filter((result) => { + if (cachedOnly && !cachedIds[result.id]) return false; + const title = result.title.toLowerCase(); + return terms.every((term) => title.includes(term)); + }) + : [...results]; // 稳定排序:相等时保留结果到达的顺序 return matched.sort(getComparator(sortKey, order)); - }, [results, terms, sortKey, order]); + }, [results, terms, cachedOnly, cachedIds, sortKey, order]); return { items, terms }; }; diff --git a/src/server/mock-search.ts b/src/server/mock-search.ts index 8c3bf44..933e1a0 100644 --- a/src/server/mock-search.ts +++ b/src/server/mock-search.ts @@ -42,6 +42,17 @@ const hex = (seed: number) => ((seed * 31 + i * 17) % 16).toString(16) ).join(""); +/** 模拟 TorBox 缓存查询:按 hash 稳定地让约三分之一的结果显示为已缓存 */ +export const checkMockCached = (hashes: string[]) => + Promise.resolve( + new Set( + hashes.filter( + (hash) => + [...hash].reduce((sum, char) => sum + parseInt(char, 16), 0) % 3 === 0 + ) + ) + ); + export const queryMockIndexer = ( indexer: Indexer, query: string, diff --git a/src/server/search.ts b/src/server/search.ts index f8ba43c..b4c65b7 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -3,8 +3,14 @@ import { INDEXER_TIMEOUT_MS, SEARCH_CONCURRENCY } from "@/constants/search"; import { isJackettConfigured, isMockMode } from "@/server/env"; import { listIndexers, queryIndexer, type Indexer } from "@/server/jackett"; import { createLogger } from "@/server/logger"; -import { getMockIndexers, queryMockIndexer } from "@/server/mock-search"; +import { + checkMockCached, + getMockIndexers, + queryMockIndexer +} from "@/server/mock-search"; +import { checkCached } from "@/server/torbox"; import type { SearchEvent, SearchResult } from "@/types/search"; +import { getMagnetInfoHash } from "@/utils/magnet"; const log = createLogger("search"); @@ -17,6 +23,12 @@ const shouldMock = () => !isJackettConfigured(); export const getSearchIndexers = (signal: AbortSignal) => shouldMock() ? Promise.resolve(getMockIndexers()) : listIndexers(signal); +/** 未配置 TorBox(演示模式)而 Jackett 是真实的:没法查缓存,不能拿模拟数据冒充 */ +const getCacheChecker = () => { + if (shouldMock()) return checkMockCached; + return isMockMode() ? null : checkCached; +}; + const describeError = (error: unknown) => { if (error instanceof DOMException && error.name === "TimeoutError") return `超时(${INDEXER_TIMEOUT_MS / 1000} 秒)`; @@ -34,6 +46,30 @@ export const runSearch = async ( emit: (event: SearchEvent) => void ) => { const queryOne = shouldMock() ? queryMockIndexer : queryIndexer; + const checker = getCacheChecker(); + // 缓存查询不阻塞结果交付:先 emit 结果,查完再补一个 cached 事件 + const cacheChecks: Promise<void>[] = []; + + const emitCached = async (results: SearchResult[]) => { + if (!checker) return; + const hashById = new Map<string, string>(); + for (const { id, magnet } of results) { + const hash = magnet && getMagnetInfoHash(magnet); + if (hash) hashById.set(id, hash); + } + if (hashById.size === 0) return; + try { + const cached = await checker([...new Set(hashById.values())]); + const ids = [...hashById].flatMap(([id, hash]) => + cached.has(hash) ? id : [] + ); + if (ids.length && !signal.aborted) emit({ type: "cached", ids }); + } catch (error) { + // 查询失败只是少了「已缓存」标记,不影响搜索结果 + log.warn({ err: error }, "查询 TorBox 缓存失败"); + } + }; + const seen = new Set<string>(); const queue = [...indexers]; const startedAt = Date.now(); @@ -78,6 +114,7 @@ export const runSearch = async ( results: fresh, error }); + if (fresh.length) cacheChecks.push(emitCached(fresh)); } }; @@ -87,6 +124,7 @@ export const runSearch = async ( worker ) ); + await Promise.all(cacheChecks); if (signal.aborted) { log.info({ durationMs: Date.now() - startedAt }, "搜索已取消"); return; diff --git a/src/server/torbox.ts b/src/server/torbox.ts index 793bcf5..38e0f25 100644 --- a/src/server/torbox.ts +++ b/src/server/torbox.ts @@ -209,6 +209,33 @@ export const addMagnet = async (magnet: string): Promise<AddTorrentResult> => { }; }; +/** checkcached 单次查询的 hash 数量上限(接口说明:受 URL 长度限制,约 100 个) */ +const CHECK_CACHED_BATCH = 100; + +/** + * 查询哪些 info hash(40 位小写十六进制)已在 TorBox 缓存。 + * 接口只返回已缓存的项,不在结果里的就是未缓存。 + */ +export const checkCached = async (hashes: string[]): Promise<Set<string>> => { + const batches: string[][] = []; + for (let i = 0; i < hashes.length; i += CHECK_CACHED_BATCH) + batches.push(hashes.slice(i, i + CHECK_CACHED_BATCH)); + + const cached = new Set<string>(); + await Promise.all( + batches.map(async (batch) => { + const params = new URLSearchParams({ + hash: batch.join(","), + format: "object" + }); + const data = await torboxFetch(`/torrents/checkcached?${params}`); + for (const hash of Object.keys(data ?? {})) + cached.add(hash.toLowerCase()); + }) + ); + return cached; +}; + const MOCK_PLAY_ERROR = "演示模式没有可播放的文件,配置 TORBOX_API_KEY 后可播放真实资源"; diff --git a/src/stores/library-filter-store.ts b/src/stores/library-filter-store.ts index 78aba33..c7d67e6 100644 --- a/src/stores/library-filter-store.ts +++ b/src/stores/library-filter-store.ts @@ -28,15 +28,22 @@ type LibraryFilterState = { setOrder: (order: SortOrder) => void; /** 清除筛选条件,排序方式保持不变 */ reset: () => void; + /** 只清除来源、下载状态、类型三个筛选项,保留关键词 */ + resetFacets: () => void; }; -const initialFilter = { - keyword: "", +const initialFacets = { source: "all", kind: "all", status: "all" } as const; +const initialFilter = { keyword: "", ...initialFacets } as const; + +/** 来源、下载状态、类型中任一项不是「全部」 */ +export const selectFacetFiltered = (state: LibraryFilterState) => + state.source !== "all" || state.kind !== "all" || state.status !== "all"; + /** 文件列表页的筛选条件(工具栏与列表共享),不持久化 */ export const useLibraryFilterStore = create<LibraryFilterState>()((set) => ({ ...initialFilter, @@ -54,5 +61,6 @@ export const useLibraryFilterStore = create<LibraryFilterState>()((set) => ({ ?.defaultOrder ?? "desc" }), setOrder: (order) => set({ order }), - reset: () => set(initialFilter) + reset: () => set(initialFilter), + resetFacets: () => set(initialFacets) })); diff --git a/src/stores/search-store.ts b/src/stores/search-store.ts index 34ce4c5..43067bd 100644 --- a/src/stores/search-store.ts +++ b/src/stores/search-store.ts @@ -34,6 +34,8 @@ type SearchState = { completed: number; /** 按到达顺序累积,排序交给展示层 */ results: SearchResult[]; + /** TorBox 已缓存的结果 id;随每次新搜索清空 */ + cachedIds: Record<string, true>; failures: SearchFailure[]; /** status 为 failed 时的原因 */ error: string | null; @@ -51,13 +53,21 @@ type SearchState = { const initialTask: Pick< SearchState, - "status" | "query" | "total" | "completed" | "results" | "failures" | "error" + | "status" + | "query" + | "total" + | "completed" + | "results" + | "cachedIds" + | "failures" + | "error" > = { status: "idle", query: "", total: 0, completed: 0, results: [], + cachedIds: {}, failures: [], error: null }; @@ -92,6 +102,13 @@ export const useSearchStore = create<SearchState>()((set, get) => { ] : state.failures })); + else if (event.type === "cached") + set((state) => ({ + cachedIds: { + ...state.cachedIds, + ...Object.fromEntries(event.ids.map((id) => [id, true as const])) + } + })); else settle({ status: "done" }, () => { const { query, results } = get(); diff --git a/src/stores/search-view-store.ts b/src/stores/search-view-store.ts index 40f3d77..da5db86 100644 --- a/src/stores/search-view-store.ts +++ b/src/stores/search-view-store.ts @@ -7,9 +7,14 @@ import type { SearchSortKey, SortOrder } from "@/types/search"; type SearchViewState = { /** 在已有结果里按标题筛选,空格分隔多个词(全部命中才显示) */ keyword: string; + /** 只显示 TorBox 已缓存的结果 */ + cachedOnly: boolean; sortKey: SearchSortKey; order: SortOrder; setKeyword: (keyword: string) => void; + setCachedOnly: (cachedOnly: boolean) => void; + /** 清除全部筛选条件(排序不动) */ + clearFilters: () => void; /** 切换排序方式,方向回到该方式的默认方向 */ setSortKey: (sortKey: SearchSortKey) => void; setOrder: (order: SortOrder) => void; @@ -18,9 +23,12 @@ type SearchViewState = { /** 结果区的视图设置(工具栏与列表共享),不持久化;与搜索任务本身(search-store)分开 */ export const useSearchViewStore = create<SearchViewState>()((set) => ({ keyword: "", + cachedOnly: false, sortKey: "seeders", order: "desc", setKeyword: (keyword) => set({ keyword }), + setCachedOnly: (cachedOnly) => set({ cachedOnly }), + clearFilters: () => set({ keyword: "", cachedOnly: false }), setSortKey: (sortKey) => set({ sortKey, diff --git a/src/types/search.ts b/src/types/search.ts index c0d89c4..05df76e 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -37,4 +37,6 @@ export type SearchEvent = /** 有值表示该 Indexer 失败,results 为空 */ error?: string; } + /** TorBox 已缓存(可免等待直接下载)的结果 id;晚于对应的 indexer 事件到达,没有出现的视为未知或未缓存 */ + | { type: "cached"; ids: string[] } | { type: "done" }; diff --git a/src/utils/magnet.ts b/src/utils/magnet.ts index 2107a68..6ec7998 100644 --- a/src/utils/magnet.ts +++ b/src/utils/magnet.ts @@ -3,6 +3,11 @@ /** btih 为 40 位十六进制或 32 位 base32 */ const BTIH = /[?&]xt=urn:btih:(?:[0-9a-f]{40}|[a-z2-7]{32})(?:&|$)/i; +/** magnet 中 40 位十六进制 info hash(小写);base32 形式或没有 hash 时为 null */ +export const getMagnetInfoHash = (magnet: string) => + magnet.match(/[?&]xt=urn:btih:([0-9a-f]{40})(?:&|$)/i)?.[1].toLowerCase() ?? + null; + /** 是否为包含合法 info hash 的 magnet 链接 */ export const isMagnetLink = (value: string) => /^magnet:\?/i.test(value) && BTIH.test(value);