From e0c403c8670f89a99521c375e1d814231ad2ec81 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Tue, 6 Oct 2026 01:27:56 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design-system.md | 1 + src/app/(app)/components/library-list.tsx | 1 + .../(app)/components/library-video-dialog.tsx | 103 ++++++++++-- src/app/(app)/components/video-playlist.tsx | 152 ++++++++++++++++++ src/components/video-player.tsx | 5 + src/constants/motion.ts | 7 + src/hooks/use-media-query.ts | 20 +++ 7 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 src/app/(app)/components/video-playlist.tsx create mode 100644 src/hooks/use-media-query.ts diff --git a/docs/design-system.md b/docs/design-system.md index 5c0123a..7fa0992 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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` | diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx index 5633d09..9c9fc7a 100644 --- a/src/app/(app)/components/library-list.tsx +++ b/src/app/(app)/components/library-list.tsx @@ -497,6 +497,7 @@ const VirtualRows = ({ items={items} token={token} target={opened?.kind === "video" ? opened : null} + onTargetChange={(target) => openFile({ ...target, kind: "video" })} onClose={closeOpened} /> diff --git a/src/app/(app)/components/library-video-dialog.tsx b/src/app/(app)/components/library-video-dialog.tsx index 415f851..ba114eb 100644 --- a/src/app/(app)/components/library-video-dialog.tsx +++ b/src/app/(app)/components/library-video-dialog.tsx @@ -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 && ( - + {/* 右侧留出关闭按钮的位置 */} @@ -100,17 +135,61 @@ export const LibraryVideoDialog = ({ {hlsUrl && " · TorBox 转码"} - + {/* 宽屏时列表在播放器右侧,高度跟随播放器;窄屏放在下方并限高 */} +
+ selectFile(nextFile))} + errorDescription="当前浏览器暂不支持播放该视频,请使用 PotPlayer 等本地播放器播放,或下载视频文件后查看。" + className={PLAYER_CLASS} + /> + + {showPlaylist && ( + + {/* 宽屏时固定宽度,面板随外层展开从右侧「移入」;h-0 + min-h-full 让列表高度跟随播放器而不撑高这一行 */} +
+ +
+
+ )} +
+
+ {hasPlaylist && ( + + )} + + ); + })} + + + ); +}; diff --git a/src/components/video-player.tsx b/src/components/video-player.tsx index f7fd373..bb24a3a 100644 --- a/src/components/video-player.tsx +++ b/src/components/video-player.tsx @@ -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} /> ) : (