diff --git a/docs/design-system.md b/docs/design-system.md
index 415ac92..5c0123a 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -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`,以数据拉取时间为「现在」 |
diff --git a/src/app/(app)/components/file-card.tsx b/src/app/(app)/components/file-card.tsx
index 059311d..2127aec 100644
--- a/src/app/(app)/components/file-card.tsx
+++ b/src/app/(app)/components/file-card.tsx
@@ -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 (
);
@@ -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 = ;
+ cover = ;
} else if (file.kind === "image" && !imageFailed) {
cover = (
-
+ setImageFailed(true)}
+ />
);
} else {
cover = (
diff --git a/src/app/(app)/components/image-cover.tsx b/src/app/(app)/components/image-cover.tsx
new file mode 100644
index 0000000..affa2e8
--- /dev/null
+++ b/src/app/(app)/components/image-cover.tsx
@@ -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 (
+
+
+
+
+
+
+ 加载中
+
+
+
+ );
+};
+
+type ImageCoverProps = {
+ src: string;
+ alt: string;
+ onOpen: () => void;
+ onError: () => void;
+};
+
+/** 图片封面:点击打开预览灯箱;图片就绪前显示加载状态,就绪后淡入 */
+export const ImageCover = ({ src, alt, onOpen, onError }: ImageCoverProps) => {
+ const [loaded, setLoaded] = useState(false);
+
+ return (
+
+ );
+};
diff --git a/src/app/(app)/components/item-download-status.tsx b/src/app/(app)/components/item-download-status.tsx
index 9c59a9f..c6e8174 100644
--- a/src/app/(app)/components/item-download-status.tsx
+++ b/src/app/(app)/components/item-download-status.tsx
@@ -35,7 +35,7 @@ export const ItemDownloadStatus = ({ item }: { item: LibraryItemView }) => {
className="hidden w-20 sm:flex"
/>
{percent}%
- {label}
+ {label}
);
};
diff --git a/src/app/(app)/components/library-image-preview.tsx b/src/app/(app)/components/library-image-preview.tsx
index 602d7f4..6904a1c 100644
--- a/src/app/(app)/components/library-image-preview.tsx
+++ b/src/app/(app)/components/library-image-preview.tsx
@@ -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 (
;
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(null);
const closeOpened = useCallback(() => setOpened(null), []);
+ // 最近播放的视频:关掉弹窗后封面保持高亮,方便找回刚看的是哪个;换一个视频后转移过去
+ const [lastPlayed, setLastPlayed] = useState(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) => (
-
- );
+ const renderRow = (item: VirtualItem) => {
+ const row = rows[item.index];
+ return (
+
+ );
+ };
const renderPiece = (piece: RowPiece) =>
piece.type === "row" ? (
renderRow(piece.item)
diff --git a/src/app/(app)/components/library-video-dialog.tsx b/src/app/(app)/components/library-video-dialog.tsx
index 5b6e474..415f851 100644
--- a/src/app/(app)/components/library-video-dialog.tsx
+++ b/src/app/(app)/components/library-video-dialog.tsx
@@ -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: () =>
- }
-);
+const VideoPlayer = dynamic(importVideoPlayer, {
+ ssr: false,
+ loading: () =>
+});
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 (