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