@@ -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` |
|
||||
|
||||
|
||||
@@ -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,10 +34,14 @@ export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<AddMagnetButton />
|
||||
{searchEnabled && <SearchButton />}
|
||||
{/* 手机宽度下播放列表与退出登录收进右上角菜单 */}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,31 +107,20 @@ 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]">
|
||||
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */}
|
||||
<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}
|
||||
@@ -145,51 +129,28 @@ export const LibraryVideoDialog = ({
|
||||
autoPlay
|
||||
autoFocus
|
||||
onEnded={nextFile && (() => selectFile(nextFile))}
|
||||
onClose={onClose}
|
||||
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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<DialogFooter className="sm:items-center">
|
||||
{hasPlaylist && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="sm:mr-auto"
|
||||
aria-expanded={playlistOpen}
|
||||
onClick={() => setPlaylistOpen((open) => !open)}
|
||||
{/* 播放列表占满剩余高度、只滚动列表;矮屏放不下列表的最小高度时整块滚动 */}
|
||||
<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}
|
||||
>
|
||||
<ListVideoIcon data-icon="inline-start" />
|
||||
{playlistOpen ? "收起播放列表" : "播放列表"}
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{position} / {playlist.length}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{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"
|
||||
@@ -199,9 +160,10 @@ export const LibraryVideoDialog = ({
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
下载
|
||||
</Button>
|
||||
{/* 交给外部播放器后关闭弹窗,避免两边同时播放 */}
|
||||
{/* 交给外部播放器后关闭,避免两边同时播放;手机上没有 PotPlayer,不显示 */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="hidden md:inline-flex"
|
||||
render={<a href={toPotPlayerUrl(streamUrl)} />}
|
||||
nativeButton={false}
|
||||
onClick={onClose}
|
||||
@@ -209,7 +171,29 @@ export const LibraryVideoDialog = ({
|
||||
<MonitorPlayIcon data-icon="inline-start" />
|
||||
PotPlayer 播放
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
{hasPlaylist && !wide && (
|
||||
<VideoPlaylist
|
||||
files={playlist}
|
||||
currentId={file.id}
|
||||
onSelect={selectFile}
|
||||
className="min-h-48 flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 宽屏:播放列表在右侧,占满高度、单独滚动 */}
|
||||
{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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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,11 +139,12 @@ 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>
|
||||
{keyword && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="清除筛选"
|
||||
@@ -147,9 +152,21 @@ export const SearchResultToolbar = ({
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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,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,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,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,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { cn } from "cn"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { cn } from "cn"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 地址")
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 后可播放真实资源";
|
||||
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,4 +37,6 @@ export type SearchEvent =
|
||||
/** 有值表示该 Indexer 失败,results 为空 */
|
||||
error?: string;
|
||||
}
|
||||
/** TorBox 已缓存(可免等待直接下载)的结果 id;晚于对应的 indexer 事件到达,没有出现的视为未知或未缓存 */
|
||||
| { type: "cached"; ids: string[] }
|
||||
| { type: "done" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user