feat: 优化交互
This commit is contained in:
@@ -195,6 +195,8 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
|
|||||||
| 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | variants `delayChildren / staggerChildren`、`TypeText` |
|
| 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | variants `delayChildren / staggerChildren`、`TypeText` |
|
||||||
| 资源卡片 | 角标准星:悬停出现,移到下一张时滑过去(120ms 内穿过间隙不隐藏),离开消失;键盘聚焦同样跟随;虚拟列表重新测量后自动贴回 | `useFollowHighlight` + `Reticle`(`bg-corner-brackets`) |
|
| 资源卡片 | 角标准星:悬停出现,移到下一张时滑过去(120ms 内穿过间隙不隐藏),离开消失;键盘聚焦同样跟随;虚拟列表重新测量后自动贴回 | `useFollowHighlight` + `Reticle`(`bg-corner-brackets`) |
|
||||||
| 视频封面 | 光标附近的点阵亮成翠绿 | `useSpotlight`(`useMotionTemplate` 径向遮罩)+ `bg-dot-grid` 的 `--dot-color / --dot-size` |
|
| 视频封面 | 光标附近的点阵亮成翠绿 | `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` |
|
| 卡片行 | 行挂载时(首屏、筛选、展开)按列依次浮现 | `FileGrid animated` + `FileGridItem` |
|
||||||
| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` |
|
| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` |
|
||||||
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
|
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
HistoryIcon,
|
||||||
HourglassIcon,
|
HourglassIcon,
|
||||||
ImageOffIcon,
|
ImageOffIcon,
|
||||||
MonitorPlayIcon,
|
MonitorPlayIcon,
|
||||||
@@ -24,7 +25,9 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { preloadVideoPlayer } from "@/components/video-player-loader";
|
||||||
import { SPOTLIGHT } from "@/constants/motion";
|
import { SPOTLIGHT } from "@/constants/motion";
|
||||||
|
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
|
||||||
import { useCopy } from "@/hooks/use-copy";
|
import { useCopy } from "@/hooks/use-copy";
|
||||||
import type { LibraryFileView } from "@/hooks/use-library";
|
import type { LibraryFileView } from "@/hooks/use-library";
|
||||||
import { useOrigin } from "@/hooks/use-origin";
|
import { useOrigin } from "@/hooks/use-origin";
|
||||||
@@ -34,6 +37,7 @@ import { formatBytes } from "@/utils/format";
|
|||||||
import type { DownloadStatus } from "@/types/torbox";
|
import type { DownloadStatus } from "@/types/torbox";
|
||||||
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
|
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
|
||||||
|
|
||||||
|
import { ImageCover } from "./image-cover";
|
||||||
import { CARD_FOCUS_PROPS } from "./library-dom";
|
import { CARD_FOCUS_PROPS } from "./library-dom";
|
||||||
|
|
||||||
const ExtLabel = ({ ext }: { ext: string }) => (
|
const ExtLabel = ({ ext }: { ext: string }) => (
|
||||||
@@ -57,18 +61,40 @@ const PlaceholderCover = ({
|
|||||||
/**
|
/**
|
||||||
* 视频封面:点击在网页内播放。点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。
|
* 视频封面:点击在网页内播放。点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。
|
||||||
* 两层同格叠放、内边距放在内容层,保证点阵对齐;按钮内只能放行内元素,层都用 span。
|
* 两层同格叠放、内边距放在内容层,保证点阵对齐;按钮内只能放行内元素,层都用 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);
|
const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT);
|
||||||
|
// 指向或聚焦时提前下载播放器;浏览器解不了的格式会走 HLS,一并预热引擎
|
||||||
|
const preload = () => preloadVideoPlayer(!BROWSER_VIDEO_EXT.has(ext));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="播放"
|
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}
|
onClick={onPlay}
|
||||||
{...handlers}
|
{...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
|
<motion.span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.5px]"
|
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">
|
<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" />
|
<PlayIcon className="size-5 fill-current" aria-hidden="true" />
|
||||||
</span>
|
</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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -155,9 +192,17 @@ type FileCardProps = {
|
|||||||
status: DownloadStatus;
|
status: DownloadStatus;
|
||||||
/** 打开文件:视频进入网页播放器,图片进入预览灯箱;两者都由列表统一渲染 */
|
/** 打开文件:视频进入网页播放器,图片进入预览灯箱;两者都由列表统一渲染 */
|
||||||
onOpen: () => void;
|
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 origin = useOrigin();
|
||||||
const copy = useCopy();
|
const copy = useCopy();
|
||||||
const [imageFailed, setImageFailed] = useState(false);
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
@@ -182,24 +227,15 @@ export const FileCard = ({ file, token, status, onOpen }: FileCardProps) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (file.kind === "video") {
|
} 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) {
|
} else if (file.kind === "image" && !imageFailed) {
|
||||||
cover = (
|
cover = (
|
||||||
<button
|
<ImageCover
|
||||||
type="button"
|
src={streamUrl}
|
||||||
title="预览图片"
|
alt={file.displayName}
|
||||||
className="flex aspect-video overflow-hidden bg-muted"
|
onOpen={onOpen}
|
||||||
onClick={onOpen}
|
onError={() => setImageFailed(true)}
|
||||||
>
|
/>
|
||||||
{/* 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>
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
cover = (
|
cover = (
|
||||||
|
|||||||
@@ -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"
|
className="hidden w-20 sm:flex"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono tabular-nums min-w-8">{percent}%</span>
|
<span className="font-mono tabular-nums min-w-8">{percent}%</span>
|
||||||
<span>{label}</span>
|
<span className="max-sm:hidden">{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
// 资源库图片预览:一个资源分组(抽屉)内的图片为一组,只在组内切换
|
// 资源库图片预览:一个资源分组(抽屉)内的图片为一组,只在组内切换
|
||||||
"use client";
|
"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 type { LibraryItemView } from "@/hooks/use-library";
|
||||||
import { useOrigin } from "@/hooks/use-origin";
|
import { useOrigin } from "@/hooks/use-origin";
|
||||||
import { buildStreamUrl } from "@/utils/media";
|
import { buildStreamUrl } from "@/utils/media";
|
||||||
|
|
||||||
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
||||||
|
|
||||||
|
/** 灯箱连同样式较大,第一次预览图片时才加载 */
|
||||||
|
const ImageLightbox = dynamic(
|
||||||
|
() => import("@/components/image-lightbox").then((mod) => mod.ImageLightbox),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
type LibraryImagePreviewProps = {
|
type LibraryImagePreviewProps = {
|
||||||
/** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */
|
/** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */
|
||||||
items: LibraryItemView[];
|
items: LibraryItemView[];
|
||||||
@@ -51,6 +58,11 @@ export const LibraryImagePreview = ({
|
|||||||
? files.findIndex((file) => file.id === target.fileId)
|
? files.findIndex((file) => file.id === target.fileId)
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
|
// 首次打开后保持挂载,关闭时才有退场动画
|
||||||
|
const [activated, setActivated] = useState(false);
|
||||||
|
if (index >= 0 && !activated) setActivated(true);
|
||||||
|
if (!activated) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
images={images}
|
images={images}
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ type RowViewProps = {
|
|||||||
index: number;
|
index: number;
|
||||||
columns: number;
|
columns: number;
|
||||||
token: string;
|
token: string;
|
||||||
|
/** 本行里最近播放的视频(文件 id),没有则为 null */
|
||||||
|
playedFileId: number | null;
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
measure: (element: HTMLDivElement | null) => void;
|
measure: (element: HTMLDivElement | null) => void;
|
||||||
onToggle: (row: HeaderRow, index: number) => void;
|
onToggle: (row: HeaderRow, index: number) => void;
|
||||||
@@ -157,6 +159,7 @@ const RowView = memo(function RowView({
|
|||||||
index,
|
index,
|
||||||
columns,
|
columns,
|
||||||
token,
|
token,
|
||||||
|
playedFileId,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
measure,
|
measure,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -202,6 +205,7 @@ const RowView = memo(function RowView({
|
|||||||
file={file}
|
file={file}
|
||||||
token={token}
|
token={token}
|
||||||
status={row.status}
|
status={row.status}
|
||||||
|
played={file.id === playedFileId}
|
||||||
onOpen={() =>
|
onOpen={() =>
|
||||||
onOpen({
|
onOpen({
|
||||||
itemKey: row.itemKey,
|
itemKey: row.itemKey,
|
||||||
@@ -376,6 +380,13 @@ const VirtualRows = ({
|
|||||||
// 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放
|
// 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放
|
||||||
const [opened, setOpened] = useState<OpenedFile | null>(null);
|
const [opened, setOpened] = useState<OpenedFile | null>(null);
|
||||||
const closeOpened = useCallback(() => setOpened(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(
|
const changePreview = useCallback(
|
||||||
(target: LibraryFileTarget | null) =>
|
(target: LibraryFileTarget | null) =>
|
||||||
setOpened(target && { ...target, kind: "image" }),
|
setOpened(target && { ...target, kind: "image" }),
|
||||||
@@ -424,19 +435,27 @@ const VirtualRows = ({
|
|||||||
virtualizer.measurementsCache,
|
virtualizer.measurementsCache,
|
||||||
virtualizer.getTotalSize()
|
virtualizer.getTotalSize()
|
||||||
);
|
);
|
||||||
const renderRow = (item: VirtualItem) => (
|
const renderRow = (item: VirtualItem) => {
|
||||||
<RowView
|
const row = rows[item.index];
|
||||||
key={item.key}
|
return (
|
||||||
row={rows[item.index]}
|
<RowView
|
||||||
index={item.index}
|
key={item.key}
|
||||||
columns={columns}
|
row={row}
|
||||||
token={token}
|
index={item.index}
|
||||||
scrollRef={scrollRef}
|
columns={columns}
|
||||||
measure={virtualizer.measureElement}
|
token={token}
|
||||||
onToggle={onToggleHeader}
|
playedFileId={
|
||||||
onOpen={setOpened}
|
row.type === "cards" && row.itemKey === lastPlayed?.itemKey
|
||||||
/>
|
? lastPlayed.fileId
|
||||||
);
|
: null
|
||||||
|
}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
measure={virtualizer.measureElement}
|
||||||
|
onToggle={onToggleHeader}
|
||||||
|
onOpen={openFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
const renderPiece = (piece: RowPiece) =>
|
const renderPiece = (piece: RowPiece) =>
|
||||||
piece.type === "row" ? (
|
piece.type === "row" ? (
|
||||||
renderRow(piece.item)
|
renderRow(piece.item)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 资源库视频网页播放弹窗
|
// 资源库视频网页播放弹窗
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { DownloadIcon, MonitorPlayIcon } from "lucide-react";
|
import { DownloadIcon, MonitorPlayIcon } from "lucide-react";
|
||||||
|
|
||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
DialogTitle
|
DialogTitle
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
importVideoPlayer,
|
||||||
|
preloadVideoPlayer
|
||||||
|
} from "@/components/video-player-loader";
|
||||||
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
|
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
|
||||||
import type { LibraryItemView } from "@/hooks/use-library";
|
import type { LibraryItemView } from "@/hooks/use-library";
|
||||||
import { useOrigin } from "@/hooks/use-origin";
|
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 PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
|
||||||
|
|
||||||
/** 播放器较大,打开弹窗时才加载 */
|
/** 播放器较大,打开弹窗时才加载 */
|
||||||
const VideoPlayer = dynamic(
|
const VideoPlayer = dynamic(importVideoPlayer, {
|
||||||
() => import("@/components/video-player").then((mod) => mod.VideoPlayer),
|
ssr: false,
|
||||||
{
|
loading: () => <Skeleton className={PLAYER_CLASS} />
|
||||||
ssr: false,
|
});
|
||||||
loading: () => <Skeleton className={PLAYER_CLASS} />
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
type LibraryVideoDialogProps = {
|
type LibraryVideoDialogProps = {
|
||||||
items: LibraryItemView[];
|
items: LibraryItemView[];
|
||||||
@@ -72,6 +73,13 @@ export const LibraryVideoDialog = ({
|
|||||||
? buildHlsUrl(origin, item.source, item.id, file.id, token)
|
? buildHlsUrl(origin, item.source, item.id, file.id, token)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎
|
||||||
|
const ready = streamUrl !== null;
|
||||||
|
const needsHls = hlsUrl !== null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) preloadVideoPlayer(needsHls);
|
||||||
|
}, [ready, needsHls]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={target !== null && streamUrl !== null}
|
open={target !== null && streamUrl !== null}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { isAxiosError } from "axios";
|
|
||||||
import { useAnimate } from "motion/react";
|
import { useAnimate } from "motion/react";
|
||||||
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
|
||||||
|
|
||||||
@@ -31,12 +30,10 @@ import {
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { SCRAMBLE, SHAKE } from "@/constants/motion";
|
import { SCRAMBLE, SHAKE } from "@/constants/motion";
|
||||||
import { useLogin } from "@/hooks/use-auth";
|
import { useLogin } from "@/hooks/use-auth";
|
||||||
|
import { isUnauthorized } from "@/lib/axios";
|
||||||
|
|
||||||
const FORM_ID = "login-form";
|
const FORM_ID = "login-form";
|
||||||
|
|
||||||
const isUnauthorized = (error: unknown) =>
|
|
||||||
isAxiosError(error) && error.response?.status === 401;
|
|
||||||
|
|
||||||
/** 表单只通过回调对外通知,不关心外部如何呈现(如背景点阵场) */
|
/** 表单只通过回调对外通知,不关心外部如何呈现(如背景点阵场) */
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
/** 演示模式的访问密钥,传入时在输入框下方提示 */
|
/** 演示模式的访问密钥,传入时在输入框下方提示 */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -7,17 +7,14 @@ import { I18nProvider } from "@videojs/react";
|
|||||||
import zhCN from "@videojs/react/i18n/locales/zh-CN";
|
import zhCN from "@videojs/react/i18n/locales/zh-CN";
|
||||||
import { Video, VideoPlayer as Player } from "@videojs/react/video";
|
import { Video, VideoPlayer as Player } from "@videojs/react/video";
|
||||||
|
|
||||||
|
import { importHlsJsVideo } from "@/components/video-player-loader";
|
||||||
import { VideoSkin } from "@/components/videojs/video/skin";
|
import { VideoSkin } from "@/components/videojs/video/skin";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HLS 播放用 hls.js 引擎:Video.js 自带的 HlsVideo 不支持 MPEG-TS 分片。
|
* HLS 播放用 hls.js 引擎:Video.js 自带的 HlsVideo 不支持 MPEG-TS 分片。
|
||||||
* hls.js 体积较大,只在播放 HLS 时加载。
|
* hls.js 体积较大,只在播放 HLS 时加载。
|
||||||
*/
|
*/
|
||||||
const HlsJsVideo = dynamic(
|
const HlsJsVideo = dynamic(importHlsJsVideo, { ssr: false });
|
||||||
() =>
|
|
||||||
import("@videojs/react/media/hlsjs-video").then((mod) => mod.HlsJsVideo),
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
type VideoPlayerProps = {
|
type VideoPlayerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
|
|||||||
@@ -140,3 +140,11 @@ export const RIPPLES = {
|
|||||||
} as const satisfies Record<string, RipplePreset>;
|
} as const satisfies Record<string, RipplePreset>;
|
||||||
|
|
||||||
export type RippleName = keyof typeof RIPPLES;
|
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;
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ BaseClientService.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 请求是否因登录态无效(401)失败 */
|
||||||
|
export const isUnauthorized = (error: unknown) =>
|
||||||
|
axios.isAxiosError(error) && error.response?.status === 401;
|
||||||
|
|
||||||
export default BaseClientService;
|
export default BaseClientService;
|
||||||
|
|||||||
Reference in New Issue
Block a user