feat: 优化交互

This commit is contained in:
2026-10-06 01:02:46 -07:00
parent 5b402d7231
commit b52d6d3afa
12 changed files with 238 additions and 53 deletions
+2
View File
@@ -195,6 +195,8 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | variants `delayChildren / staggerChildren`、`TypeText` |
| 资源卡片 | 角标准星:悬停出现,移到下一张时滑过去(120ms 内穿过间隙不隐藏),离开消失;键盘聚焦同样跟随;虚拟列表重新测量后自动贴回 | `useFollowHighlight` + `Reticle`(`bg-corner-brackets`) |
| 视频封面 | 光标附近的点阵亮成翠绿 | `useSpotlight`(`useMotionTemplate` 径向遮罩)+ `bg-dot-grid` 的 `--dot-color / --dot-size` |
| 上次播放 | 打开过的视频封面底色染成翠绿、点阵常亮并标「上次播放」;关掉弹窗后仍保留,换看另一个时转移过去(只标一个,刷新页面后清除) | `LibraryList` 持有 `lastPlayed`,经行 prop `playedFileId` 传给 `FileCard`;封面用 `data-played` 切换样式 |
| 图片封面加载 | 图片就绪前,翠绿点阵被一道光带反复扫亮并显示「加载中」;加载超过 0.15s 才出现,缓存命中不闪;就绪后图片淡入、覆盖层淡出;减弱动态效果时只留静态点阵与转圈 | `ImageCover` + `IMAGE_LOADING`(`animate` 驱动 MotionValue 生成遮罩,不触发 React 渲染) |
| 卡片行 | 行挂载时(首屏、筛选、展开)按列依次浮现 | `FileGrid animated` + `FileGridItem` |
| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` |
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
+56 -20
View File
@@ -9,6 +9,7 @@ import {
DownloadIcon,
EyeIcon,
FileIcon,
HistoryIcon,
HourglassIcon,
ImageOffIcon,
MonitorPlayIcon,
@@ -24,7 +25,9 @@ import {
TooltipContent,
TooltipTrigger
} from "@/components/ui/tooltip";
import { preloadVideoPlayer } from "@/components/video-player-loader";
import { SPOTLIGHT } from "@/constants/motion";
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
import { useCopy } from "@/hooks/use-copy";
import type { LibraryFileView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
@@ -34,6 +37,7 @@ import { formatBytes } from "@/utils/format";
import type { DownloadStatus } from "@/types/torbox";
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
import { ImageCover } from "./image-cover";
import { CARD_FOCUS_PROPS } from "./library-dom";
const ExtLabel = ({ ext }: { ext: string }) => (
@@ -57,18 +61,40 @@ const PlaceholderCover = ({
/**
* 视频封面:点击在网页内播放。点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。
* 两层同格叠放、内边距放在内容层,保证点阵对齐;按钮内只能放行内元素,层都用 span。
* played:刚看过的那个视频,底色染上翠绿并标出「上次播放」,关掉弹窗后一眼能找回。
*/
const VideoCover = ({ ext, onPlay }: { ext: string; onPlay: () => void }) => {
const VideoCover = ({
ext,
played,
onPlay
}: {
ext: string;
played: boolean;
onPlay: () => void;
}) => {
const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT);
// 指向或聚焦时提前下载播放器;浏览器解不了的格式会走 HLS,一并预热引擎
const preload = () => preloadVideoPlayer(!BROWSER_VIDEO_EXT.has(ext));
return (
<button
type="button"
title="播放"
className="group/cover grid aspect-video bg-muted bg-dot-grid *:col-start-1 *:row-start-1"
className="group/cover grid aspect-video bg-muted bg-dot-grid transition-colors duration-300 *:col-start-1 *:row-start-1 data-[played]:bg-[color-mix(in_oklab,var(--primary)_16%,var(--muted))]"
data-played={played ? "" : undefined}
onClick={onPlay}
{...handlers}
onPointerEnter={(event) => {
handlers.onPointerEnter(event);
preload();
}}
onFocus={preload}
>
{/* 看过的封面:整片点阵常亮为翠绿 */}
<span
aria-hidden="true"
className="bg-dot-grid opacity-0 transition-opacity duration-300 [--dot-color:var(--primary)] [--dot-size:1.5px] group-data-[played]/cover:opacity-45"
/>
<motion.span
aria-hidden="true"
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.5px]"
@@ -81,7 +107,18 @@ const VideoCover = ({ ext, onPlay }: { ext: string; onPlay: () => void }) => {
<span className="flex size-11 items-center justify-center self-center rounded-full bg-primary text-primary-foreground transition-[scale,box-shadow] group-hover/cover:scale-110 group-hover/cover:shadow-[0_0_0_6px] group-hover/cover:shadow-primary/20">
<PlayIcon className="size-5 fill-current" aria-hidden="true" />
</span>
<span className="h-5" aria-hidden="true" />
{played ? (
<motion.span
className="flex h-5 items-center gap-1 font-mono text-xs text-primary"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
>
<HistoryIcon className="size-3.5" aria-hidden="true" />
上次播放
</motion.span>
) : (
<span className="h-5" aria-hidden="true" />
)}
</span>
</button>
);
@@ -155,9 +192,17 @@ type FileCardProps = {
status: DownloadStatus;
/** 打开文件:视频进入网页播放器,图片进入预览灯箱;两者都由列表统一渲染 */
onOpen: () => void;
/** 这是最近播放的视频,封面高亮 */
played?: boolean;
};
export const FileCard = ({ file, token, status, onOpen }: FileCardProps) => {
export const FileCard = ({
file,
token,
status,
onOpen,
played = false
}: FileCardProps) => {
const origin = useOrigin();
const copy = useCopy();
const [imageFailed, setImageFailed] = useState(false);
@@ -182,24 +227,15 @@ export const FileCard = ({ file, token, status, onOpen }: FileCardProps) => {
/>
);
} else if (file.kind === "video") {
cover = <VideoCover ext={file.ext} onPlay={onOpen} />;
cover = <VideoCover ext={file.ext} played={played} onPlay={onOpen} />;
} else if (file.kind === "image" && !imageFailed) {
cover = (
<button
type="button"
title="预览图片"
className="flex aspect-video overflow-hidden bg-muted"
onClick={onOpen}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamUrl}
alt={file.displayName}
loading="lazy"
className="size-full object-cover transition-transform hover:scale-105"
onError={() => setImageFailed(true)}
/>
</button>
<ImageCover
src={streamUrl}
alt={file.displayName}
onOpen={onOpen}
onError={() => setImageFailed(true)}
/>
);
} else {
cover = (
+88
View File
@@ -0,0 +1,88 @@
// 图片封面:加载中用点阵扫光 + 转圈提示,加载完成后淡入
"use client";
import { useEffect, useState } from "react";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform
} from "motion/react";
import { Spinner } from "@/components/ui/spinner";
import { IMAGE_LOADING } from "@/constants/motion";
/** 加载中的覆盖层:翠绿点阵上有一道光带从左扫到右,中间是转圈与文字 */
const LoadingOverlay = () => {
const reduced = useReducedMotion();
const progress = useMotionValue(0);
// 光带在 -40% ~ 140% 之间移动,起止都在封面之外
const maskImage = useTransform(progress, (value) => {
const head = value * 180 - 40;
return `linear-gradient(100deg, transparent ${head - 40}%, #000 ${head - 15}%, transparent ${head}%)`;
});
useEffect(() => {
if (reduced) return;
const controls = animate(progress, 1, IMAGE_LOADING.sweep);
return () => controls.stop();
}, [reduced, progress]);
return (
<motion.span
aria-hidden="true"
className="grid grid-cols-1 grid-rows-1 *:col-start-1 *:row-start-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: IMAGE_LOADING.showDelay } }}
exit={{ opacity: 0, transition: IMAGE_LOADING.fade }}
>
<span className="bg-dot-grid opacity-60 [--dot-color:var(--primary)] [--dot-size:1px]" />
<motion.span
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.8px]"
style={{ maskImage: reduced ? undefined : maskImage }}
/>
<span className="flex items-center justify-center">
<span className="flex items-center gap-1.5 rounded-full bg-background/70 py-1 pr-2.5 pl-2 font-mono text-xs text-primary backdrop-blur-sm">
<Spinner className="size-3.5" />
加载中
</span>
</span>
</motion.span>
);
};
type ImageCoverProps = {
src: string;
alt: string;
onOpen: () => void;
onError: () => void;
};
/** 图片封面:点击打开预览灯箱;图片就绪前显示加载状态,就绪后淡入 */
export const ImageCover = ({ src, alt, onOpen, onError }: ImageCoverProps) => {
const [loaded, setLoaded] = useState(false);
return (
<button
type="button"
title="预览图片"
className="grid aspect-video grid-cols-1 grid-rows-1 overflow-hidden bg-muted *:col-start-1 *:row-start-1"
aria-busy={!loaded}
onClick={onOpen}
>
<AnimatePresence>{!loaded && <LoadingOverlay />}</AnimatePresence>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
loading="lazy"
className="size-full object-cover opacity-0 transition-[opacity,scale] duration-300 hover:scale-105 data-[loaded]:opacity-100"
data-loaded={loaded ? "" : undefined}
onLoad={() => setLoaded(true)}
onError={onError}
/>
</button>
);
};
@@ -35,7 +35,7 @@ export const ItemDownloadStatus = ({ item }: { item: LibraryItemView }) => {
className="hidden w-20 sm:flex"
/>
<span className="font-mono tabular-nums min-w-8">{percent}%</span>
<span>{label}</span>
<span className="max-sm:hidden">{label}</span>
</span>
);
};
@@ -1,15 +1,22 @@
// 资源库图片预览:一个资源分组(抽屉)内的图片为一组,只在组内切换
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { ImageLightbox, type LightboxImage } from "@/components/image-lightbox";
import type { LightboxImage } from "@/components/image-lightbox";
import type { LibraryItemView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
import { buildStreamUrl } from "@/utils/media";
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
/** 灯箱连同样式较大,第一次预览图片时才加载 */
const ImageLightbox = dynamic(
() => import("@/components/image-lightbox").then((mod) => mod.ImageLightbox),
{ ssr: false }
);
type LibraryImagePreviewProps = {
/** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */
items: LibraryItemView[];
@@ -51,6 +58,11 @@ export const LibraryImagePreview = ({
? files.findIndex((file) => file.id === target.fileId)
: -1;
// 首次打开后保持挂载,关闭时才有退场动画
const [activated, setActivated] = useState(false);
if (index >= 0 && !activated) setActivated(true);
if (!activated) return null;
return (
<ImageLightbox
images={images}
+32 -13
View File
@@ -145,6 +145,8 @@ type RowViewProps = {
index: number;
columns: number;
token: string;
/** 本行里最近播放的视频(文件 id),没有则为 null */
playedFileId: number | null;
scrollRef: React.RefObject<HTMLDivElement | null>;
measure: (element: HTMLDivElement | null) => void;
onToggle: (row: HeaderRow, index: number) => void;
@@ -157,6 +159,7 @@ const RowView = memo(function RowView({
index,
columns,
token,
playedFileId,
scrollRef,
measure,
onToggle,
@@ -202,6 +205,7 @@ const RowView = memo(function RowView({
file={file}
token={token}
status={row.status}
played={file.id === playedFileId}
onOpen={() =>
onOpen({
itemKey: row.itemKey,
@@ -376,6 +380,13 @@ const VirtualRows = ({
// 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放
const [opened, setOpened] = useState<OpenedFile | null>(null);
const closeOpened = useCallback(() => setOpened(null), []);
// 最近播放的视频:关掉弹窗后封面保持高亮,方便找回刚看的是哪个;换一个视频后转移过去
const [lastPlayed, setLastPlayed] = useState<LibraryFileTarget | null>(null);
const openFile = useCallback((file: OpenedFile) => {
setOpened(file);
if (file.kind === "video")
setLastPlayed({ itemKey: file.itemKey, fileId: file.fileId });
}, []);
const changePreview = useCallback(
(target: LibraryFileTarget | null) =>
setOpened(target && { ...target, kind: "image" }),
@@ -424,19 +435,27 @@ const VirtualRows = ({
virtualizer.measurementsCache,
virtualizer.getTotalSize()
);
const renderRow = (item: VirtualItem) => (
<RowView
key={item.key}
row={rows[item.index]}
index={item.index}
columns={columns}
token={token}
scrollRef={scrollRef}
measure={virtualizer.measureElement}
onToggle={onToggleHeader}
onOpen={setOpened}
/>
);
const renderRow = (item: VirtualItem) => {
const row = rows[item.index];
return (
<RowView
key={item.key}
row={row}
index={item.index}
columns={columns}
token={token}
playedFileId={
row.type === "cards" && row.itemKey === lastPlayed?.itemKey
? lastPlayed.fileId
: null
}
scrollRef={scrollRef}
measure={virtualizer.measureElement}
onToggle={onToggleHeader}
onOpen={openFile}
/>
);
};
const renderPiece = (piece: RowPiece) =>
piece.type === "row" ? (
renderRow(piece.item)
@@ -1,7 +1,7 @@
// 资源库视频网页播放弹窗
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { DownloadIcon, MonitorPlayIcon } from "lucide-react";
@@ -15,6 +15,10 @@ import {
DialogTitle
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import {
importVideoPlayer,
preloadVideoPlayer
} from "@/components/video-player-loader";
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
import type { LibraryItemView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
@@ -27,13 +31,10 @@ import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
/** 播放器较大,打开弹窗时才加载 */
const VideoPlayer = dynamic(
() => import("@/components/video-player").then((mod) => mod.VideoPlayer),
{
ssr: false,
loading: () => <Skeleton className={PLAYER_CLASS} />
}
);
const VideoPlayer = dynamic(importVideoPlayer, {
ssr: false,
loading: () => <Skeleton className={PLAYER_CLASS} />
});
type LibraryVideoDialogProps = {
items: LibraryItemView[];
@@ -72,6 +73,13 @@ export const LibraryVideoDialog = ({
? buildHlsUrl(origin, item.source, item.id, file.id, token)
: null;
// 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎
const ready = streamUrl !== null;
const needsHls = hlsUrl !== null;
useEffect(() => {
if (ready) preloadVideoPlayer(needsHls);
}, [ready, needsHls]);
return (
<Dialog
open={target !== null && streamUrl !== null}
+1 -4
View File
@@ -2,7 +2,6 @@
"use client";
import { useState } from "react";
import { isAxiosError } from "axios";
import { useAnimate } from "motion/react";
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
@@ -31,12 +30,10 @@ import {
import { Spinner } from "@/components/ui/spinner";
import { SCRAMBLE, SHAKE } from "@/constants/motion";
import { useLogin } from "@/hooks/use-auth";
import { isUnauthorized } from "@/lib/axios";
const FORM_ID = "login-form";
const isUnauthorized = (error: unknown) =>
isAxiosError(error) && error.response?.status === 401;
/** 表单只通过回调对外通知,不关心外部如何呈现(如背景点阵场) */
type LoginFormProps = {
/** 演示模式的访问密钥,传入时在输入框下方提示 */
+14
View File
@@ -0,0 +1,14 @@
// 视频播放器的按需加载入口:播放器与 hls.js 引擎都是独立 chunk,这里统一加载并支持提前预热
// 同一个 import() 只会请求一次 chunk,预热后 next/dynamic 直接命中缓存
export const importVideoPlayer = () =>
import("@/components/video-player").then((mod) => mod.VideoPlayer);
export const importHlsJsVideo = () =>
import("@videojs/react/media/hlsjs-video").then((mod) => mod.HlsJsVideo);
/** 提前下载播放器 chunk;需要 HLS 时并行下载引擎,避免点开后串行等待 */
export const preloadVideoPlayer = (hls: boolean) => {
void importVideoPlayer();
if (hls) void importHlsJsVideo();
};
+2 -5
View File
@@ -7,17 +7,14 @@ 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 { VideoSkin } from "@/components/videojs/video/skin";
/**
* HLS 播放用 hls.js 引擎:Video.js 自带的 HlsVideo 不支持 MPEG-TS 分片。
* hls.js 体积较大,只在播放 HLS 时加载。
*/
const HlsJsVideo = dynamic(
() =>
import("@videojs/react/media/hlsjs-video").then((mod) => mod.HlsJsVideo),
{ ssr: false }
);
const HlsJsVideo = dynamic(importHlsJsVideo, { ssr: false });
type VideoPlayerProps = {
src: string;
+8
View File
@@ -140,3 +140,11 @@ export const RIPPLES = {
} as const satisfies Record<string, RipplePreset>;
export type RippleName = keyof typeof RIPPLES;
/** 图片封面加载中:点阵被一道光带扫亮 */
export const IMAGE_LOADING = {
/** 加载超过这个时间(秒)才出现,缓存命中时不闪一下 */
showDelay: 0.15,
fade: { duration: 0.3 },
sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }
} as const;
+4
View File
@@ -29,4 +29,8 @@ BaseClientService.interceptors.response.use(
}
);
/** 请求是否因登录态无效(401)失败 */
export const isUnauthorized = (error: unknown) =>
axios.isAxiosError(error) && error.response?.status === 401;
export default BaseClientService;