feat: 新增播放列表
This commit is contained in:
@@ -203,6 +203,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
|
||||
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
|
||||
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
|
||||
| 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
|
||||
| 视频播放列表 | 弹窗里同一资源的视频超过一个时,底栏左侧出现「播放列表 3 / 12」开关,列表默认收起(收起时播放器占满宽度),展开状态在弹窗关闭后保留;展开时列表从右侧滑入、播放器随之收窄,收起时滑出(窄屏改为在播放器下方展开 / 收起),参数见 `PLAYLIST_SLIDE`:宽屏在播放器右侧、高度跟随播放器,窄屏在下方限高;点击切换、上一个 / 下一个按钮,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 |
|
||||
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
|
||||
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |
|
||||
|
||||
|
||||
@@ -497,6 +497,7 @@ const VirtualRows = ({
|
||||
items={items}
|
||||
token={token}
|
||||
target={opened?.kind === "video" ? opened : null}
|
||||
onTargetChange={(target) => openFile({ ...target, kind: "video" })}
|
||||
onClose={closeOpened}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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 {
|
||||
@@ -19,13 +20,17 @@ import {
|
||||
importVideoPlayer,
|
||||
preloadVideoPlayer
|
||||
} from "@/components/video-player-loader";
|
||||
import { PLAYLIST_SLIDE } from "@/constants/motion";
|
||||
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
|
||||
import type { LibraryItemView } from "@/hooks/use-library";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useOrigin } from "@/hooks/use-origin";
|
||||
import { formatBytes } from "@/utils/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media";
|
||||
|
||||
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
||||
import { VideoPlaylist } from "./video-playlist";
|
||||
|
||||
/** 16:9,矮屏时限制高度给标题与操作栏留位置 */
|
||||
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
|
||||
@@ -40,6 +45,8 @@ type LibraryVideoDialogProps = {
|
||||
items: LibraryItemView[];
|
||||
token: string;
|
||||
target: LibraryFileTarget | null;
|
||||
/** 在播放列表中切换到同一资源内的另一个视频 */
|
||||
onTargetChange: (target: LibraryFileTarget) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -51,11 +58,16 @@ export const LibraryVideoDialog = ({
|
||||
items,
|
||||
token,
|
||||
target,
|
||||
onTargetChange,
|
||||
onClose
|
||||
}: LibraryVideoDialogProps) => {
|
||||
const origin = useOrigin();
|
||||
const wide = useMediaQuery("(min-width: 1024px)");
|
||||
const reduced = useReducedMotion();
|
||||
// 关闭动画期间 target 已清空,继续显示刚才的视频
|
||||
const [shown, setShown] = useState(target);
|
||||
// 播放列表默认收起,展开状态在弹窗关闭后保留
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||
if (target && target !== shown) setShown(target);
|
||||
|
||||
const item = shown ? findLibraryItem(items, shown.itemKey) : undefined;
|
||||
@@ -63,6 +75,24 @@ export const LibraryVideoDialog = ({
|
||||
item?.status === "ready"
|
||||
? item.files.find((entry) => entry.id === shown?.fileId)
|
||||
: 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 =
|
||||
file && origin
|
||||
? buildStreamUrl(origin, file.playUrl, file.displayName, token)
|
||||
@@ -88,7 +118,12 @@ export const LibraryVideoDialog = ({
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<DialogTitle className="truncate leading-snug" title={file.name}>
|
||||
@@ -100,6 +135,8 @@ export const LibraryVideoDialog = ({
|
||||
{hlsUrl && " · TorBox 转码"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* 宽屏时列表在播放器右侧,高度跟随播放器;窄屏放在下方并限高 */}
|
||||
<div className="grid min-w-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<VideoPlayer
|
||||
key={hlsUrl ?? streamUrl}
|
||||
src={hlsUrl ?? streamUrl}
|
||||
@@ -107,10 +144,52 @@ export const LibraryVideoDialog = ({
|
||||
title={file.displayName}
|
||||
autoPlay
|
||||
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">
|
||||
{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
|
||||
variant="outline"
|
||||
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;
|
||||
/** 挂载后把焦点交给播放器,空格、方向键等快捷键直接可用(快捷键只在焦点位于播放器内时生效) */
|
||||
autoFocus?: boolean;
|
||||
/** 播放到结尾(播放列表据此接下一个) */
|
||||
onEnded?: () => void;
|
||||
/** 替换默认的播放错误说明 */
|
||||
errorDescription?: ReactNode;
|
||||
/** 尺寸与比例,如 aspect-video */
|
||||
@@ -43,6 +45,7 @@ export const VideoPlayer = ({
|
||||
title,
|
||||
autoPlay,
|
||||
autoFocus,
|
||||
onEnded,
|
||||
errorDescription,
|
||||
className
|
||||
}: VideoPlayerProps) => {
|
||||
@@ -68,6 +71,7 @@ export const VideoPlayer = ({
|
||||
autoPlay={autoPlay}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
) : (
|
||||
<Video
|
||||
@@ -75,6 +79,7 @@ export const VideoPlayer = ({
|
||||
autoPlay={autoPlay}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
)}
|
||||
</VideoSkin>
|
||||
|
||||
@@ -148,3 +148,10 @@ export const IMAGE_LOADING = {
|
||||
fade: { duration: 0.3 },
|
||||
sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }
|
||||
} 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