Files
DndGamePlayer/app/renderer/shared/ContainedVideo.tsx
T
Ivan Fontosh 1fbaaa6e77 feat(scene): video map editor parity and help updates
Enable scene editor, overlays, effects, and darkness on video scenes; brighten GM trap markers; document snap, NPC types, materials, and control controls in RU/EN help.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 08:23:08 +08:00

159 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { containMediaRect } from '../../shared/types/containMediaRect';
import { DEFAULT_SCENE_VIEW_CAMERA, type SceneViewCamera } from '../../shared/types/sceneView';
import styles from './ContainedVideo.module.css';
export type ContainedVideoProps = {
url: string;
/** Зум/пан как у RotatedImage в mode=contain. */
viewCamera?: SceneViewCamera | null;
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
videoRef?: React.Ref<HTMLVideoElement | null>;
loop?: boolean;
muted?: boolean;
playsInline?: boolean;
preload?: React.VideoHTMLAttributes<HTMLVideoElement>['preload'];
className?: string;
style?: React.CSSProperties;
onTimeUpdate?: React.VideoHTMLAttributes<HTMLVideoElement>['onTimeUpdate'];
onLoadedMetadata?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedMetadata'];
onError?: React.VideoHTMLAttributes<HTMLVideoElement>['onError'];
children?: React.ReactNode;
};
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const readLayoutSize = () => {
setSize({ w: el.clientWidth, h: el.clientHeight });
};
const ro = new ResizeObserver(() => {
readLayoutSize();
});
ro.observe(el);
readLayoutSize();
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
function assignRef<T>(ref: React.Ref<T> | undefined, value: T): void {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
return;
}
(ref as React.MutableRefObject<T>).current = value;
}
/**
* Video laid out like RotatedImage(mode=contain): reports the visible content rect
* so grid / traps / tokens / effects align with the letterboxed frame.
*/
export function ContainedVideo({
url,
viewCamera = null,
onContentRectChange,
videoRef,
loop = false,
muted = false,
playsInline = true,
preload = 'auto',
className,
style,
onTimeUpdate,
onLoadedMetadata,
onError,
children,
}: ContainedVideoProps) {
const [hostRef, size] = useElementSize<HTMLDivElement>();
const [mediaSize, setMediaSize] = useState<{ w: number; h: number } | null>(null);
const elRef = useRef<HTMLVideoElement | null>(null);
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
const viewScale = Math.max(1, cam.scale);
const viewOx = cam.ox;
const viewOy = cam.oy;
const syncMediaSize = (el: HTMLVideoElement) => {
const w0 = el.videoWidth || 0;
const h0 = el.videoHeight || 0;
if (w0 <= 0 || h0 <= 0) return;
setMediaSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
};
useLayoutEffect(() => {
const el = elRef.current;
if (!el) return;
if (el.readyState >= 1) syncMediaSize(el);
}, [url]);
const contentRect = useMemo(() => {
if (!mediaSize) return null;
return containMediaRect({
hostW: size.w,
hostH: size.h,
mediaW: mediaSize.w,
mediaH: mediaSize.h,
scale: viewScale,
ox: viewOx,
oy: viewOy,
});
}, [mediaSize, size.h, size.w, viewOx, viewOy, viewScale]);
useEffect(() => {
if (!onContentRectChange || !contentRect) return;
onContentRectChange(contentRect);
}, [contentRect, onContentRectChange]);
const w = contentRect?.w;
const h = contentRect?.h;
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
return (
<div
ref={hostRef}
className={[styles.root, className].filter(Boolean).join(' ')}
style={style}
>
<video
ref={(el) => {
elRef.current = el;
assignRef(videoRef, el);
}}
className={styles.video}
src={url}
loop={loop}
muted={muted}
playsInline={playsInline}
preload={preload}
draggable={false}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={(e) => {
syncMediaSize(e.currentTarget);
onLoadedMetadata?.(e);
}}
onError={onError}
style={{
width: w ?? '100%',
height: h ?? '100%',
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
objectFit: mediaSize ? undefined : 'contain',
transform: 'translate(-50%, -50%)',
}}
>
{children}
</video>
</div>
);
}