feat(canvas): 视频节点 - 接入 Vidstack 播放器并收敛任务查询范围
This commit is contained in:
@@ -17,6 +17,7 @@ import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canv
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
@@ -883,14 +884,8 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
|
||||
}
|
||||
|
||||
function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRendererProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const playWhenReadyRef = useRef(false);
|
||||
const { url, loading, load } = useNodeResourceUrl(node, false);
|
||||
useEffect(() => {
|
||||
if (!url || !playWhenReadyRef.current) return;
|
||||
playWhenReadyRef.current = false;
|
||||
void videoRef.current?.play().catch(() => undefined);
|
||||
}, [url]);
|
||||
if (!node.metadata?.content)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
|
||||
@@ -901,7 +896,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRender
|
||||
if (!url) {
|
||||
return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
|
||||
}
|
||||
return <video ref={videoRef} src={url} controls preload={reduceMediaEffects ? "none" : "metadata"} className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
|
||||
return <VideoPlayer src={url} mimeType={node.metadata?.mimeType} title={node.title || "视频"} preload={reduceMediaEffects ? "none" : "metadata"} autoPlay={playWhenReadyRef.current} onCanPlay={() => { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[18px] bg-black" dataCanvasNoZoom compactControls />;
|
||||
}
|
||||
|
||||
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
|
||||
|
||||
17
web/src/components/video-player.css
Normal file
17
web/src/components/video-player.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.canvas-video-player [data-media-provider] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.canvas-video-player [data-media-provider] video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 画布节点顶部还有标题和存储状态,隐藏 Vidstack 小布局的顶部控制组避免互相遮挡。 */
|
||||
.canvas-video-player-compact .vds-video-layout[data-sm] .vds-controls-group:first-child {
|
||||
display: none;
|
||||
}
|
||||
102
web/src/components/video-player.tsx
Normal file
102
web/src/components/video-player.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties, ComponentProps } from "react";
|
||||
import { MediaPlayer, MediaProvider, type VideoMimeType } from "@vidstack/react";
|
||||
import { DefaultVideoLayout, defaultLayoutIcons, type DefaultLayoutTranslations } from "@vidstack/react/player/layouts/default";
|
||||
import "@vidstack/react/player/styles/base.css";
|
||||
import "@vidstack/react/player/styles/default/theme.css";
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css";
|
||||
import "./video-player.css";
|
||||
|
||||
type MediaPlayerProps = ComponentProps<typeof MediaPlayer>;
|
||||
|
||||
type VideoPlayerProps = {
|
||||
src: string;
|
||||
mimeType?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
brandColor?: string;
|
||||
preload?: MediaPlayerProps["preload"];
|
||||
autoPlay?: boolean;
|
||||
dataCanvasNoZoom?: boolean;
|
||||
compactControls?: boolean;
|
||||
onCanPlay?: MediaPlayerProps["onCanPlay"];
|
||||
};
|
||||
|
||||
const zhCNTranslations = {
|
||||
Accessibility: "辅助功能",
|
||||
AirPlay: "隔空播放",
|
||||
Audio: "音频",
|
||||
Auto: "自动",
|
||||
Boost: "音量增强",
|
||||
Captions: "字幕",
|
||||
"Caption Styles": "字幕样式",
|
||||
Chapters: "章节",
|
||||
"Closed-Captions Off": "关闭字幕",
|
||||
"Closed-Captions On": "开启字幕",
|
||||
Connected: "已连接",
|
||||
Connecting: "连接中",
|
||||
Default: "默认",
|
||||
Disabled: "已禁用",
|
||||
Disconnected: "已断开",
|
||||
Download: "下载",
|
||||
"Enter Fullscreen": "进入全屏",
|
||||
"Enter PiP": "进入画中画",
|
||||
"Exit Fullscreen": "退出全屏",
|
||||
"Exit PiP": "退出画中画",
|
||||
Fullscreen: "全屏",
|
||||
Loop: "循环播放",
|
||||
Mute: "静音",
|
||||
Normal: "正常",
|
||||
Off: "关闭",
|
||||
Pause: "暂停",
|
||||
Play: "播放",
|
||||
Playback: "播放",
|
||||
PiP: "画中画",
|
||||
Quality: "画质",
|
||||
Replay: "重新播放",
|
||||
Reset: "重置",
|
||||
Seek: "跳转",
|
||||
"Seek Backward": "快退",
|
||||
"Seek Forward": "快进",
|
||||
Settings: "设置",
|
||||
Speed: "倍速",
|
||||
Unmute: "取消静音",
|
||||
Volume: "音量",
|
||||
} satisfies Partial<DefaultLayoutTranslations>;
|
||||
|
||||
const supportedVideoMimeTypes = new Set<VideoMimeType>(["video/mp4", "video/webm", "video/3gp", "video/ogg", "video/avi", "video/mpeg", "video/object"]);
|
||||
|
||||
/**
|
||||
* 统一视频播放表面,保留原生媒体 URL 契约,同时提供可访问的完整控件布局。
|
||||
* 画布节点需要隔离播放器手势,避免拖动进度条时被误判为拖动画布。
|
||||
*/
|
||||
export function VideoPlayer({ src, mimeType, title = "视频", className, brandColor = "#f5f5f5", preload = "metadata", autoPlay = false, dataCanvasNoZoom = false, compactControls = false, onCanPlay }: VideoPlayerProps) {
|
||||
const stopCanvasControlInteraction = (event: { target: EventTarget | null; stopPropagation: () => void }) => {
|
||||
if (!dataCanvasNoZoom || !(event.target instanceof Element)) return;
|
||||
if (event.target.closest(".vds-controls,.vds-menu-items")) event.stopPropagation();
|
||||
};
|
||||
const type = mimeType && supportedVideoMimeTypes.has(mimeType as VideoMimeType) ? (mimeType as VideoMimeType) : "video/mp4";
|
||||
const mediaSource = useMemo(() => ({ src, type }), [src, type]);
|
||||
|
||||
return (
|
||||
<MediaPlayer
|
||||
className={`canvas-video-player ${compactControls ? "canvas-video-player-compact" : ""} ${className || ""}`}
|
||||
src={mediaSource}
|
||||
title={title}
|
||||
viewType="video"
|
||||
streamType="on-demand"
|
||||
playsInline
|
||||
autoPlay={autoPlay}
|
||||
load="eager"
|
||||
preload={preload}
|
||||
data-canvas-no-zoom={dataCanvasNoZoom ? "true" : undefined}
|
||||
style={{ "--video-brand": brandColor } as CSSProperties}
|
||||
onCanPlay={onCanPlay}
|
||||
onPointerDown={stopCanvasControlInteraction}
|
||||
onMouseDown={stopCanvasControlInteraction}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} translations={zhCNTranslations} />
|
||||
</MediaPlayer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user