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` | | 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | 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`,以数据拉取时间为「现在」 |
+56 -20
View File
@@ -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 = (
+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" 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}
+32 -13
View File
@@ -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}
+1 -4
View File
@@ -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 = {
/** 演示模式的访问密钥,传入时在输入框下方提示 */ /** 演示模式的访问密钥,传入时在输入框下方提示 */
+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 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;
+8
View File
@@ -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;
+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; export default BaseClientService;