feat: 新增播放列表
This commit is contained in:
@@ -203,6 +203,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
|
|||||||
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
|
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
|
||||||
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
|
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
|
||||||
| 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
|
| 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
|
||||||
|
| 视频播放列表 | 弹窗里同一资源的视频超过一个时,底栏左侧出现「播放列表 3 / 12」开关,列表默认收起(收起时播放器占满宽度),展开状态在弹窗关闭后保留;展开时列表从右侧滑入、播放器随之收窄,收起时滑出(窄屏改为在播放器下方展开 / 收起),参数见 `PLAYLIST_SLIDE`:宽屏在播放器右侧、高度跟随播放器,窄屏在下方限高;点击切换、上一个 / 下一个按钮,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 |
|
||||||
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
|
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
|
||||||
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |
|
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |
|
||||||
|
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ const VirtualRows = ({
|
|||||||
items={items}
|
items={items}
|
||||||
token={token}
|
token={token}
|
||||||
target={opened?.kind === "video" ? opened : null}
|
target={opened?.kind === "video" ? opened : null}
|
||||||
|
onTargetChange={(target) => openFile({ ...target, kind: "video" })}
|
||||||
onClose={closeOpened}
|
onClose={closeOpened}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
import { useEffect, 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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import { DownloadIcon, ListVideoIcon, MonitorPlayIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -19,13 +20,17 @@ import {
|
|||||||
importVideoPlayer,
|
importVideoPlayer,
|
||||||
preloadVideoPlayer
|
preloadVideoPlayer
|
||||||
} from "@/components/video-player-loader";
|
} from "@/components/video-player-loader";
|
||||||
|
import { PLAYLIST_SLIDE } from "@/constants/motion";
|
||||||
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 { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { useOrigin } from "@/hooks/use-origin";
|
import { useOrigin } from "@/hooks/use-origin";
|
||||||
import { formatBytes } from "@/utils/format";
|
import { formatBytes } from "@/utils/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
|
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
|
||||||
|
|
||||||
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
||||||
|
import { VideoPlaylist } from "./video-playlist";
|
||||||
|
|
||||||
/** 16:9,矮屏时限制高度给标题与操作栏留位置 */
|
/** 16:9,矮屏时限制高度给标题与操作栏留位置 */
|
||||||
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
|
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
|
||||||
@@ -40,6 +45,8 @@ type LibraryVideoDialogProps = {
|
|||||||
items: LibraryItemView[];
|
items: LibraryItemView[];
|
||||||
token: string;
|
token: string;
|
||||||
target: LibraryFileTarget | null;
|
target: LibraryFileTarget | null;
|
||||||
|
/** 在播放列表中切换到同一资源内的另一个视频 */
|
||||||
|
onTargetChange: (target: LibraryFileTarget) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,11 +58,16 @@ export const LibraryVideoDialog = ({
|
|||||||
items,
|
items,
|
||||||
token,
|
token,
|
||||||
target,
|
target,
|
||||||
|
onTargetChange,
|
||||||
onClose
|
onClose
|
||||||
}: LibraryVideoDialogProps) => {
|
}: LibraryVideoDialogProps) => {
|
||||||
const origin = useOrigin();
|
const origin = useOrigin();
|
||||||
|
const wide = useMediaQuery("(min-width: 1024px)");
|
||||||
|
const reduced = useReducedMotion();
|
||||||
// 关闭动画期间 target 已清空,继续显示刚才的视频
|
// 关闭动画期间 target 已清空,继续显示刚才的视频
|
||||||
const [shown, setShown] = useState(target);
|
const [shown, setShown] = useState(target);
|
||||||
|
// 播放列表默认收起,展开状态在弹窗关闭后保留
|
||||||
|
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||||
if (target && target !== shown) setShown(target);
|
if (target && target !== shown) setShown(target);
|
||||||
|
|
||||||
const item = shown ? findLibraryItem(items, shown.itemKey) : undefined;
|
const item = shown ? findLibraryItem(items, shown.itemKey) : undefined;
|
||||||
@@ -63,6 +75,24 @@ export const LibraryVideoDialog = ({
|
|||||||
item?.status === "ready"
|
item?.status === "ready"
|
||||||
? item.files.find((entry) => entry.id === shown?.fileId)
|
? item.files.find((entry) => entry.id === shown?.fileId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// 播放列表:资源内的视频,顺序与列表中一致;只有一个视频时不显示
|
||||||
|
const playlist =
|
||||||
|
item?.status === "ready"
|
||||||
|
? item.files.filter((entry) => entry.kind === "video")
|
||||||
|
: [];
|
||||||
|
const hasPlaylist = playlist.length > 1;
|
||||||
|
const showPlaylist = hasPlaylist && playlistOpen;
|
||||||
|
// 宽屏从右侧滑入(宽度),窄屏从播放器下方展开(高度)
|
||||||
|
const collapsed = wide ? { width: 0, opacity: 0 } : { height: 0, opacity: 0 };
|
||||||
|
const expanded = wide
|
||||||
|
? { width: PLAYLIST_SLIDE.width, opacity: 1 }
|
||||||
|
: { height: "auto", opacity: 1 };
|
||||||
|
const position = playlist.findIndex((entry) => entry.id === file?.id) + 1;
|
||||||
|
const selectFile = (next: { id: number }) =>
|
||||||
|
shown && onTargetChange({ itemKey: shown.itemKey, fileId: next.id });
|
||||||
|
const nextFile = file
|
||||||
|
? playlist[playlist.findIndex((entry) => entry.id === file.id) + 1]
|
||||||
|
: undefined;
|
||||||
const streamUrl =
|
const streamUrl =
|
||||||
file && origin
|
file && origin
|
||||||
? buildStreamUrl(origin, file.playUrl, file.displayName, token)
|
? buildStreamUrl(origin, file.playUrl, file.displayName, token)
|
||||||
@@ -88,7 +118,12 @@ export const LibraryVideoDialog = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file && streamUrl && (
|
{file && streamUrl && (
|
||||||
<DialogContent className="grid-cols-1 gap-3 sm:max-w-5xl">
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"grid-cols-1 gap-3 sm:max-w-5xl",
|
||||||
|
hasPlaylist && "lg:max-w-6xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* 右侧留出关闭按钮的位置 */}
|
{/* 右侧留出关闭按钮的位置 */}
|
||||||
<DialogHeader className="gap-1 pr-8">
|
<DialogHeader className="gap-1 pr-8">
|
||||||
<DialogTitle className="truncate leading-snug" title={file.name}>
|
<DialogTitle className="truncate leading-snug" title={file.name}>
|
||||||
@@ -100,17 +135,61 @@ export const LibraryVideoDialog = ({
|
|||||||
{hlsUrl && " · TorBox 转码"}
|
{hlsUrl && " · TorBox 转码"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<VideoPlayer
|
{/* 宽屏时列表在播放器右侧,高度跟随播放器;窄屏放在下方并限高 */}
|
||||||
key={hlsUrl ?? streamUrl}
|
<div className="grid min-w-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
src={hlsUrl ?? streamUrl}
|
<VideoPlayer
|
||||||
hls={hlsUrl !== null}
|
key={hlsUrl ?? streamUrl}
|
||||||
title={file.displayName}
|
src={hlsUrl ?? streamUrl}
|
||||||
autoPlay
|
hls={hlsUrl !== null}
|
||||||
autoFocus
|
title={file.displayName}
|
||||||
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
|
autoPlay
|
||||||
className={PLAYER_CLASS}
|
autoFocus
|
||||||
/>
|
onEnded={nextFile && (() => selectFile(nextFile))}
|
||||||
|
errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。"
|
||||||
|
className={PLAYER_CLASS}
|
||||||
|
/>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{showPlaylist && (
|
||||||
|
<motion.div
|
||||||
|
// 跨过断点时重建,避免沿用另一个方向的内联尺寸
|
||||||
|
key={wide ? "wide" : "narrow"}
|
||||||
|
className="min-w-0 overflow-hidden"
|
||||||
|
initial={collapsed}
|
||||||
|
animate={expanded}
|
||||||
|
exit={collapsed}
|
||||||
|
transition={
|
||||||
|
reduced ? { duration: 0 } : PLAYLIST_SLIDE.transition
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 宽屏时固定宽度,面板随外层展开从右侧「移入」;h-0 + min-h-full 让列表高度跟随播放器而不撑高这一行 */}
|
||||||
|
<div className="pt-3 lg:h-full lg:w-[284px] lg:pt-0 lg:pl-3">
|
||||||
|
<VideoPlaylist
|
||||||
|
files={playlist}
|
||||||
|
currentId={file.id}
|
||||||
|
onSelect={selectFile}
|
||||||
|
className="max-h-56 lg:h-0 lg:max-h-none lg:min-h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
<DialogFooter className="sm:items-center">
|
<DialogFooter className="sm:items-center">
|
||||||
|
{hasPlaylist && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="sm:mr-auto"
|
||||||
|
aria-expanded={playlistOpen}
|
||||||
|
onClick={() => setPlaylistOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<ListVideoIcon data-icon="inline-start" />
|
||||||
|
{playlistOpen ? "收起播放列表" : "播放列表"}
|
||||||
|
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{position} / {playlist.length}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// 视频弹窗的播放列表:同一资源分组内的视频,点击切换,播完自动接下一个
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
PlayIcon,
|
||||||
|
type LucideIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { LibraryFileView } from "@/hooks/use-library";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatBytes } from "@/utils/format";
|
||||||
|
|
||||||
|
type StepButtonProps = {
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StepButton = ({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
disabled,
|
||||||
|
onClick
|
||||||
|
}: StepButtonProps) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
type VideoPlaylistProps = {
|
||||||
|
files: LibraryFileView[];
|
||||||
|
/** 正在播放的文件 id */
|
||||||
|
currentId: number;
|
||||||
|
onSelect: (file: LibraryFileView) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoPlaylist = ({
|
||||||
|
files,
|
||||||
|
currentId,
|
||||||
|
onSelect,
|
||||||
|
className
|
||||||
|
}: VideoPlaylistProps) => {
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const index = files.findIndex((file) => file.id === currentId);
|
||||||
|
const previous = files[index - 1];
|
||||||
|
const next = files[index + 1];
|
||||||
|
|
||||||
|
// 切换后(含自动接下一个)让当前项出现在可视范围内
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current
|
||||||
|
?.querySelector("[aria-current=true]")
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [currentId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="播放列表"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center gap-2 border-b py-1.5 pr-1.5 pl-3">
|
||||||
|
<h3 className="text-sm font-medium">播放列表</h3>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{index + 1} / {files.length}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
|
<StepButton
|
||||||
|
label="上一个"
|
||||||
|
icon={ChevronUpIcon}
|
||||||
|
disabled={!previous}
|
||||||
|
onClick={() => previous && onSelect(previous)}
|
||||||
|
/>
|
||||||
|
<StepButton
|
||||||
|
label="下一个"
|
||||||
|
icon={ChevronDownIcon}
|
||||||
|
disabled={!next}
|
||||||
|
onClick={() => next && onSelect(next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
className="flex min-h-0 flex-1 flex-col overflow-y-auto p-1"
|
||||||
|
>
|
||||||
|
{files.map((file, position) => {
|
||||||
|
const current = file.id === currentId;
|
||||||
|
return (
|
||||||
|
<li key={file.id} className="flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={current}
|
||||||
|
title={file.name}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors outline-none",
|
||||||
|
"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
current && "bg-primary/10 text-primary hover:bg-primary/15"
|
||||||
|
)}
|
||||||
|
onClick={() => !current && onSelect(file)}
|
||||||
|
>
|
||||||
|
<span className="flex w-5 shrink-0 justify-center font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{current ? (
|
||||||
|
<PlayIcon
|
||||||
|
className="size-3.5 fill-current text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
position + 1
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate leading-snug">
|
||||||
|
{file.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
<span className="uppercase">{file.ext || "file"}</span> ·{" "}
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,6 +26,8 @@ type VideoPlayerProps = {
|
|||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
/** 挂载后把焦点交给播放器,空格、方向键等快捷键直接可用(快捷键只在焦点位于播放器内时生效) */
|
/** 挂载后把焦点交给播放器,空格、方向键等快捷键直接可用(快捷键只在焦点位于播放器内时生效) */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
/** 播放到结尾(播放列表据此接下一个) */
|
||||||
|
onEnded?: () => void;
|
||||||
/** 替换默认的播放错误说明 */
|
/** 替换默认的播放错误说明 */
|
||||||
errorDescription?: ReactNode;
|
errorDescription?: ReactNode;
|
||||||
/** 尺寸与比例,如 aspect-video */
|
/** 尺寸与比例,如 aspect-video */
|
||||||
@@ -43,6 +45,7 @@ export const VideoPlayer = ({
|
|||||||
title,
|
title,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
onEnded,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
className
|
className
|
||||||
}: VideoPlayerProps) => {
|
}: VideoPlayerProps) => {
|
||||||
@@ -68,6 +71,7 @@ export const VideoPlayer = ({
|
|||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
onEnded={onEnded}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Video
|
<Video
|
||||||
@@ -75,6 +79,7 @@ export const VideoPlayer = ({
|
|||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
onEnded={onEnded}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</VideoSkin>
|
</VideoSkin>
|
||||||
|
|||||||
@@ -148,3 +148,10 @@ export const IMAGE_LOADING = {
|
|||||||
fade: { duration: 0.3 },
|
fade: { duration: 0.3 },
|
||||||
sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }
|
sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** 视频弹窗的播放列表:展开时从右侧滑入,收起时滑出,播放器随之伸缩 */
|
||||||
|
export const PLAYLIST_SLIDE = {
|
||||||
|
/** 宽屏时列表栏宽度(px),含与播放器之间的 12px 间距 */
|
||||||
|
width: 284,
|
||||||
|
transition: { duration: 0.32, ease: [0.22, 1, 0.36, 1] }
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 媒体查询 hook
|
||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/** 订阅媒体查询结果;服务端渲染阶段按不匹配处理 */
|
||||||
|
export const useMediaQuery = (query: string) => {
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(onChange: () => void) => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
media.addEventListener("change", onChange);
|
||||||
|
return () => media.removeEventListener("change", onChange);
|
||||||
|
},
|
||||||
|
[query]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
() => window.matchMedia(query).matches,
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user