From b52d6d3afa4a069da116f032a135a0ea4d254238 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Tue, 6 Oct 2026 01:02:46 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design-system.md | 2 + src/app/(app)/components/file-card.tsx | 76 +++++++++++----- src/app/(app)/components/image-cover.tsx | 88 +++++++++++++++++++ .../(app)/components/item-download-status.tsx | 2 +- .../components/library-image-preview.tsx | 16 +++- src/app/(app)/components/library-list.tsx | 45 +++++++--- .../(app)/components/library-video-dialog.tsx | 24 +++-- src/app/login/components/login-form.tsx | 5 +- src/components/video-player-loader.ts | 14 +++ src/components/video-player.tsx | 7 +- src/constants/motion.ts | 8 ++ src/lib/axios.ts | 4 + 12 files changed, 238 insertions(+), 53 deletions(-) create mode 100644 src/app/(app)/components/image-cover.tsx create mode 100644 src/components/video-player-loader.ts 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 ( +