feat: 新增播放列表

This commit is contained in:
2026-10-06 01:27:56 -07:00
parent b52d6d3afa
commit e0c403c867
7 changed files with 277 additions and 12 deletions
+1
View File
@@ -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"
+152
View File
@@ -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>
);
};
+5
View File
@@ -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>
+7
View File
@@ -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;
+20
View File
@@ -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
);
};