feat: 布局交互优化
torbox-admin / deploy (push) Successful in 2m29s

This commit is contained in:
2026-10-06 09:21:03 -07:00
parent f016f02e58
commit e8f62bc89a
47 changed files with 877 additions and 343 deletions
+6 -4
View File
@@ -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` |
+8 -3
View File
@@ -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) => (
<div className="ml-auto flex items-center gap-1">
<AddMagnetButton />
{searchEnabled && <SearchButton />}
<PlaylistMenu token={token} />
<Separator orientation="vertical" className="mx-1 my-4" />
<LogoutButton />
{/* 手机宽度下播放列表与退出登录收进右上角菜单 */}
<div className="hidden md:contents">
<PlaylistMenu token={token} />
<Separator orientation="vertical" className="mx-1 my-4" />
<LogoutButton />
</div>
<MobileMenu token={token} />
</div>
</div>
</header>
@@ -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<T extends string> = {
layout: FilterLayout;
/** 筛选维度名,如「来源」;分段控件的读屏名为「按来源筛选」 */
title: string;
/** stacked 时的等宽列数(完整类名,供 Tailwind 扫描) */
columns: string;
value: T;
onChange: (value: T) => void;
children: React.ReactNode;
};
/** inline:分段控件;stacked:带标题的等宽网格,窄面板里按列换行 */
const FilterGroup = <T extends string>({
layout,
title,
columns,
value,
onChange,
children
}: FilterGroupProps<T>) => {
const stacked = layout === "stacked";
const group = (
// 单选:再次点击已选项时保持当前值,不允许清空
<ToggleGroup
aria-label={`按${title}筛选`}
variant="outline"
size="sm"
spacing={stacked ? 1.5 : 0}
className={stacked ? cn("grid w-full", columns) : undefined}
value={[value]}
onValueChange={(next) => next[0] && onChange(next[0] as T)}
>
{children}
</ToggleGroup>
);
if (!stacked) return group;
return (
<FieldSet className="gap-0">
<FieldLegend variant="label">{title}</FieldLegend>
{group}
</FieldSet>
);
};
type LibraryFiltersProps = {
/** 各来源在关键词、类型、状态筛选后的资源数 */
sourceCounts: Record<SourceFilter, number>;
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 (
<>
<FilterGroup
layout={layout}
title="来源"
columns="grid-cols-2"
value={source}
onChange={setSource}
>
<ToggleGroupItem value="all">
全部
<AnimatedNumber
value={sourceCounts.all}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
</ToggleGroupItem>
{SOURCES.map(({ type }) => (
<ToggleGroupItem key={type} value={type}>
<SourceDot source={type} />
{SOURCE_LABELS[type]}
<AnimatedNumber
value={sourceCounts[type]}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
</ToggleGroupItem>
))}
</FilterGroup>
<FilterGroup
layout={layout}
title="下载状态"
columns="grid-cols-3"
value={status}
onChange={setStatus}
>
{STATUS_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</FilterGroup>
<FilterGroup
layout={layout}
title="文件类型"
columns="grid-cols-4"
value={kind}
onChange={setKind}
>
{KIND_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</FilterGroup>
</>
);
};
+6 -90
View File
@@ -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<HTMLInputElement>(null);
@@ -255,69 +230,10 @@ export const LibraryToolbar = ({
</InputGroupAddon>
</InputGroup>
{/* 单选:再次点击已选项时保持当前值,不允许清空 */}
<ToggleGroup
aria-label="按来源筛选"
variant="outline"
size="sm"
spacing={0}
value={[source]}
onValueChange={(value) =>
value[0] && setSource(value[0] as SourceFilter)
}
>
<ToggleGroupItem value="all">
全部
<AnimatedNumber
value={sourceCounts.all}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
</ToggleGroupItem>
{SOURCES.map(({ type }) => (
<ToggleGroupItem key={type} value={type}>
<SourceDot source={type} />
{SOURCE_LABELS[type]}
<AnimatedNumber
value={sourceCounts[type]}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
<ToggleGroup
aria-label="按下载状态筛选"
variant="outline"
size="sm"
spacing={0}
value={[status]}
onValueChange={(value) =>
value[0] && setStatus(value[0] as StatusFilter)
}
>
{STATUS_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<ToggleGroup
aria-label="按文件类型筛选"
variant="outline"
size="sm"
spacing={0}
value={[kind]}
onValueChange={(value) => value[0] && setKind(value[0] as KindFilter)}
>
{KIND_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* 手机宽度下三个筛选项收进顶栏菜单(MobileMenu) */}
<div className="hidden md:contents">
<LibraryFilters sourceCounts={sourceCounts} />
</div>
<SortMenu />
<ExpandAllButton
+94 -110
View File
@@ -1,17 +1,15 @@
// 资源库视频网页播放弹窗
// 资源库视频播放:全屏打开,视频在上、信息在下,播放列表手机在下方、宽屏在右侧
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { DownloadIcon, ListVideoIcon, MonitorPlayIcon } from "lucide-react";
import { DownloadIcon, MonitorPlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
@@ -20,22 +18,25 @@ import {
importVideoPlayer,
preloadVideoPlayer
} from "@/components/video-player-loader";
import { PLAYLIST_SLIDE } from "@/constants/motion";
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
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 { formatBytes } from "@/utils/format";
import { cn } from "@/lib/utils";
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
import { VideoPlaylist } from "./video-playlist";
/** 16:9,矮屏时限制高度给标题与操作栏留位置 */
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
/**
* 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置。
* 手机宽度下贴边、去掉圆角
*/
const PLAYER_CLASS =
"mx-auto aspect-video w-full max-w-[calc((100dvh-9rem)*16/9)] max-md:rounded-none";
/** 播放器较大,打开弹窗时才加载 */
/** 播放器较大,打开时才加载 */
const VideoPlayer = dynamic(importVideoPlayer, {
ssr: false,
loading: () => <Skeleton className={PLAYER_CLASS} />
@@ -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 (
<Dialog
open={target !== null && streamUrl !== null}
onOpenChange={(open) => {
if (!open) onClose();
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
{file && streamUrl && (
<DialogContent
className={cn(
"grid-cols-1 gap-3 sm:max-w-5xl",
hasPlaylist && "lg:max-w-6xl"
)}
showCloseButton={false}
// 全屏:关闭按钮在视频左上角,返回键也能关闭
className="inset-0 flex max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none p-0 ring-0 sm:max-w-none lg:flex-row"
>
{/* 右侧留出关闭按钮的位置 */}
<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>
{/* 宽屏时列表在播放器右侧,高度跟随播放器;窄屏放在下方并限高 */}
<div className="grid min-w-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto]">
<VideoPlayer
key={hlsUrl ?? streamUrl}
src={hlsUrl ?? streamUrl}
hls={hlsUrl !== null}
title={file.displayName}
autoPlay
autoFocus
onEnded={nextFile && (() => selectFile(nextFile))}
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
className={PLAYER_CLASS}
/>
<AnimatePresence initial={false}>
{showPlaylist && (
<motion.div
// 跨过断点时重建,避免沿用另一个方向的内联尺寸
key={wide ? "wide" : "narrow"}
className="min-w-0 overflow-hidden"
initial={collapsed}
animate={expanded}
exit={collapsed}
transition={
reduced ? { duration: 0 } : PLAYLIST_SLIDE.transition
}
>
{/* 宽屏时固定宽度,面板随外层展开从右侧「移入」;h-0 + min-h-full 让列表高度跟随播放器而不撑高这一行 */}
<div className="pt-3 lg:h-full lg:w-[284px] lg:pt-0 lg:pl-3">
<VideoPlaylist
files={playlist}
currentId={file.id}
onSelect={selectFile}
className="max-h-56 lg:h-0 lg:max-h-none lg:min-h-full"
/>
</div>
</motion.div>
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="shrink-0 md:px-4 md:pt-4">
<VideoPlayer
key={hlsUrl ?? streamUrl}
src={hlsUrl ?? streamUrl}
hls={hlsUrl !== null}
title={file.displayName}
autoPlay
autoFocus
onEnded={nextFile && (() => selectFile(nextFile))}
onClose={onClose}
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
className={PLAYER_CLASS}
/>
</div>
{/* 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动 */}
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<div className="flex shrink-0 items-start gap-3">
<DialogHeader className="min-w-0 flex-1 gap-1.5">
<DialogTitle
className="line-clamp-2 leading-snug wrap-anywhere"
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>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
render={<a href={streamUrl} download={file.displayName} />}
nativeButton={false}
>
<DownloadIcon data-icon="inline-start" />
下载
</Button>
{/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */}
<Button
size="sm"
className="hidden md:inline-flex"
render={<a href={toPotPlayerUrl(streamUrl)} />}
nativeButton={false}
onClick={onClose}
>
<MonitorPlayIcon data-icon="inline-start" />
PotPlayer 播放
</Button>
</div>
</div>
{hasPlaylist && !wide && (
<VideoPlaylist
files={playlist}
currentId={file.id}
onSelect={selectFile}
className="min-h-48 flex-1"
/>
)}
</AnimatePresence>
</div>
</div>
<DialogFooter className="sm:items-center">
{hasPlaylist && (
<Button
variant="ghost"
size="sm"
className="sm:mr-auto"
aria-expanded={playlistOpen}
onClick={() => setPlaylistOpen((open) => !open)}
>
<ListVideoIcon data-icon="inline-start" />
{playlistOpen ? "收起播放列表" : "播放列表"}
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{position} / {playlist.length}
</span>
</Button>
)}
<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>
{/* 宽屏:播放列表在右侧,占满高度、单独滚动 */}
{hasPlaylist && wide && (
<div className="flex w-88 shrink-0 flex-col py-4 pr-4">
<VideoPlaylist
files={playlist}
currentId={file.id}
onSelect={selectFile}
className="min-h-0 flex-1"
/>
</div>
)}
</DialogContent>
)}
</Dialog>
+15 -1
View File
@@ -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 (
<Button variant="outline" disabled={isPending} onClick={() => mutate()}>
{isPending ? (
<Spinner data-icon="inline-start" />
) : (
<LogOutIcon data-icon="inline-start" />
)}
退出登录
</Button>
);
}
return (
<Tooltip>
<TooltipTrigger
+138
View File
@@ -0,0 +1,138 @@
// 手机顶栏菜单:右侧滑出,收纳资源筛选、M3U 播放列表与退出登录
"use client";
import { AnimatePresence, motion } from "motion/react";
import { CopyIcon, MenuIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet";
import { INDICATOR_DOT } from "@/constants/motion";
import { useFilteredLibrary } from "@/hooks/use-library";
import { usePlaylist } from "@/hooks/use-playlist";
import {
selectFacetFiltered,
useLibraryFilterStore
} from "@/stores/library-filter-store";
import { LibraryFilters } from "./library-filters";
import { LogoutButton } from "./logout-button";
/** 只在菜单打开时挂载,来源计数不在后台重复计算 */
const MenuFilters = () => {
const { sourceCounts } = useFilteredLibrary();
const filtered = useLibraryFilterStore(selectFacetFiltered);
const resetFacets = useLibraryFilterStore((state) => state.resetFacets);
return (
<section aria-labelledby="mobile-menu-filters" className="flex flex-col gap-3">
<div className="flex items-center">
<h3 id="mobile-menu-filters" className="text-sm font-medium">
筛选
</h3>
<Button
variant="ghost"
size="xs"
className="ml-auto"
disabled={!filtered}
onClick={resetFacets}
>
重置
</Button>
</div>
<FieldGroup className="gap-4">
<LibraryFilters sourceCounts={sourceCounts} layout="stacked" />
</FieldGroup>
</section>
);
};
const MenuPlaylist = ({ token }: { token: string }) => {
const playlist = usePlaylist(token);
return (
<section aria-labelledby="mobile-menu-playlist" className="flex flex-col gap-1">
<h3
id="mobile-menu-playlist"
className="flex items-center gap-2 pb-2 text-sm font-medium"
>
播放列表
<span className="font-mono text-xs text-muted-foreground">
全部资源 · M3U
</span>
</h3>
{/* 手机上没有 PotPlayer,只提供复制地址给其他播放器 */}
<Button
variant="ghost"
className="justify-start"
disabled={!playlist.url}
onClick={playlist.copyUrl}
>
<CopyIcon data-icon="inline-start" />
复制 M3U 地址
</Button>
</section>
);
};
/** 汉堡按钮:来源、下载状态、类型任一项有筛选时,右上角亮起翠绿提示点 */
const MenuTrigger = () => {
const filtered = useLibraryFilterStore(selectFacetFiltered);
return (
<SheetTrigger
render={
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={filtered ? "菜单(已启用筛选)" : "菜单"}
/>
}
>
{/* 单格 grid 叠层:提示点叠在图标右上角 */}
<span className="grid">
<MenuIcon className="col-start-1 row-start-1" />
<AnimatePresence initial={false}>
{filtered && (
<motion.span
aria-hidden="true"
className="col-start-1 row-start-1 size-2 translate-x-1 -translate-y-1 self-start justify-self-end rounded-full bg-primary ring-2 ring-background"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={INDICATOR_DOT.transition}
/>
)}
</AnimatePresence>
</span>
</SheetTrigger>
);
};
export const MobileMenu = ({ token }: { token: string }) => (
<Sheet>
<MenuTrigger />
<SheetContent className="gap-0">
<SheetHeader className="border-b">
<SheetTitle>菜单</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
<MenuFilters />
<Separator />
<MenuPlaylist token={token} />
</div>
<SheetFooter className="border-t">
<LogoutButton labeled />
</SheetFooter>
</SheetContent>
</Sheet>
);
+7 -16
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" disabled={!playlistUrl} />}
render={<Button variant="ghost" disabled={!playlist.url} />}
>
<ListVideoIcon data-icon="inline-start" />
{/* 手机宽度下只显示图标,文字留给读屏 */}
<span className="sr-only sm:not-sr-only">播放列表</span>
播放列表
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>全部资源 · M3U</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => window.location.assign(toPotPlayerUrl(playlistUrl))}
>
<DropdownMenuItem onClick={playlist.openInPotPlayer}>
<PlayIcon />用 PotPlayer 打开
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => copy(playlistUrl, "已复制 M3U 地址")}
>
<DropdownMenuItem onClick={playlist.copyUrl}>
<CopyIcon />
复制 M3U 地址
</DropdownMenuItem>
+6 -5
View File
@@ -80,7 +80,7 @@ const SearchForm = () => {
event.preventDefault();
if (!keyword) return;
// 新结果与旧的筛选词无关,清掉避免一上来就是空列表
useSearchViewStore.getState().setKeyword("");
useSearchViewStore.getState().clearFilters();
void start(keyword);
}}
>
@@ -184,7 +184,8 @@ const SearchResults = () => {
const results = useSearchStore((state) => state.results);
const sortKey = useSearchViewStore((state) => state.sortKey);
const order = useSearchViewStore((state) => state.order);
const setKeyword = useSearchViewStore((state) => state.setKeyword);
const cachedOnly = useSearchViewStore((state) => state.cachedOnly);
const clearFilters = useSearchViewStore((state) => state.clearFilters);
const { items, terms } = useSearchResults(results);
if (results.length > 0)
@@ -195,7 +196,7 @@ const SearchResults = () => {
<SearchResultList
items={items}
terms={terms}
resetScrollKey={`${sortKey}:${order}:${terms.join(" ")}`}
resetScrollKey={`${sortKey}:${order}:${terms.join(" ")}:${cachedOnly}`}
className={RESULT_HEIGHT}
/>
) : (
@@ -206,10 +207,10 @@ const SearchResults = () => {
</EmptyMedia>
<EmptyTitle>没有匹配的结果</EmptyTitle>
<EmptyDescription>
共 {results.length} 条结果中没有同时包含这些词的
共 {results.length} 条结果中没有符合当前筛选条件的
</EmptyDescription>
</EmptyHeader>
<Button variant="outline" size="sm" onClick={() => setKeyword("")}>
<Button variant="outline" size="sm" onClick={clearFilters}>
清除筛选
</Button>
</Empty>
@@ -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 (
<div className="flex flex-col gap-2 border-b px-1 py-3">
@@ -182,6 +184,16 @@ const ResultRow = memo(function ResultRow({
{leechers != null && (
<Stat label="下载" icon={ArrowDownIcon} value={leechers} />
)}
{cached && (
<Badge
variant="secondary"
className="text-primary"
title="TorBox 已缓存,添加后可立即播放"
>
<ZapIcon />
已缓存
</Badge>
)}
</div>
{/* 窄屏换到下一行,靠右 */}
<div className="flex items-center gap-1 self-end sm:self-auto">
@@ -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 (
<div className="flex items-center gap-2">
@@ -135,21 +139,34 @@ export const SearchResultToolbar = ({
aria-label="在结果中筛选"
autoComplete="off"
/>
{keyword && (
{(keyword || cachedOnly) && (
<InputGroupAddon align="inline-end">
<InputGroupText className="font-mono text-xs tabular-nums">
{shown} / {total}
</InputGroupText>
<InputGroupButton
size="icon-xs"
aria-label="清除筛选"
onClick={() => setKeyword("")}
>
<XIcon />
</InputGroupButton>
{keyword && (
<InputGroupButton
size="icon-xs"
aria-label="清除筛选"
onClick={() => setKeyword("")}
>
<XIcon />
</InputGroupButton>
)}
</InputGroupAddon>
)}
</InputGroup>
<Toggle
variant="outline"
size="lg"
className="shrink-0 aria-pressed:text-primary"
pressed={cachedOnly}
onPressedChange={setCachedOnly}
aria-label="只显示 TorBox 已缓存的结果"
>
<ZapIcon />
已缓存
</Toggle>
<SortMenu />
</div>
);
+5 -65
View File
@@ -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) => (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label={label}
disabled={disabled}
onClick={onClick}
/>
}
>
<Icon />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
type VideoPlaylistProps = {
files: LibraryFileView[];
/** 正在播放的文件 id */
@@ -66,8 +24,6 @@ export const VideoPlaylist = ({
}: VideoPlaylistProps) => {
const listRef = useRef<HTMLUListElement>(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 (
<section
aria-label="播放列表"
className={cn(
"flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card",
className
)}
className={cn("flex min-h-0 flex-col gap-1", className)}
>
<header className="flex shrink-0 items-center gap-2 border-b py-1.5 pr-1.5 pl-3">
<header className="flex shrink-0 items-baseline gap-2">
<h3 className="text-sm font-medium">播放列表</h3>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{index + 1} / {files.length}
</span>
<div className="ml-auto flex items-center">
<StepButton
label="上一个"
icon={ChevronUpIcon}
disabled={!previous}
onClick={() => previous && onSelect(previous)}
/>
<StepButton
label="下一个"
icon={ChevronDownIcon}
disabled={!next}
onClick={() => next && onSelect(next)}
/>
</div>
</header>
{/* 内容多时只滚动列表;左右外扩让悬停与当前项底色铺出去,文字与标题对齐,p-1 给焦点环留位置 */}
<ul
ref={listRef}
className="flex min-h-0 flex-1 flex-col overflow-y-auto p-1"
className="-mx-3 flex min-h-0 flex-1 flex-col overflow-y-auto p-1"
>
{files.map((file, position) => {
const current = file.id === currentId;
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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!",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { cn } from "cn"
import { cn } from "@/lib/utils";
function Card({
className,
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -1,4 +1,4 @@
import { cn } from "cn"
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+138
View File
@@ -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 <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { cn } from "cn"
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
+1 -1
View File
@@ -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">) {
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
+23 -1
View File
@@ -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<HTMLDivElement>(null);
@@ -64,7 +70,23 @@ export const VideoPlayer = ({
<div ref={rootRef} className="contents">
<I18nProvider locale="zh-CN" translations={zhCN}>
<Player title={title}>
<VideoSkin className={className} errorDescription={errorDescription}>
<VideoSkin
className={className}
errorDescription={errorDescription}
topStart={
onClose && (
<ButtonTooltip side="bottom" label="关闭">
<MediaButton
type="button"
aria-label="关闭"
onClick={onClose}
>
<ChevronLeftIcon className="size-media-icon drop-shadow-media-icon" />
</MediaButton>
</ButtonTooltip>
)
}
>
{hls ? (
<HlsJsVideo
src={src}
+29 -2
View File
@@ -1,6 +1,7 @@
'use client';
import '../styles/video/base.css';
import { Tooltip } from '@videojs/react';
import type { ComponentProps, ReactNode } from 'react';
import { BufferingIndicator } from '@/components/videojs/ui/buffering-indicator';
@@ -21,6 +22,8 @@ export interface VideoSkinProps extends Omit<NonNullable<ComponentProps<typeof C
renderThumbnail?: NonNullable<ComponentProps<typeof DefaultVideoControls>>['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 (
<Container
className={cn('pointer-fine:not-data-controls-visible:cursor-none', className)}
className={cn('group/skin pointer-fine:not-data-controls-visible:cursor-none', className)}
data-theme="default"
data-preset="video"
{...props}
@@ -42,7 +46,30 @@ export function VideoSkin({
<Poster renderImage={renderPoster} />
<BufferingIndicator />
<ErrorDialog description={errorDescription} />
<Title />
{/* 有左上角操作时标题向右让出位置 */}
<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} />
+3 -5
View File
@@ -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;
+31
View File
@@ -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]);
};
+17
View File
@@ -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 地址")
};
};
+12 -7
View File
@@ -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 };
};
+11
View File
@@ -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,
+39 -1
View File
@@ -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;
+27
View File
@@ -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 后可播放真实资源";
+11 -3
View File
@@ -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)
}));
+18 -1
View File
@@ -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();
+8
View File
@@ -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,
+2
View File
@@ -37,4 +37,6 @@ export type SearchEvent =
/** 有值表示该 Indexer 失败,results 为空 */
error?: string;
}
/** TorBox 已缓存(可免等待直接下载)的结果 id;晚于对应的 indexer 事件到达,没有出现的视为未知或未缓存 */
| { type: "cached"; ids: string[] }
| { type: "done" };
+5
View File
@@ -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);