Files
DndGamePlayer/app/renderer/control/ControlScenePreview.tsx
Ivan Fontosh 0f4bb76b41 feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:42:57 +08:00

211 lines
7.9 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import type { SceneViewCamera } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { ContainedVideo } from '../shared/ContainedVideo';
import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
import styles from './ControlScenePreview.module.css';
type Props = {
session: SessionState | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
viewCamera?: SceneViewCamera | null;
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
};
function fmt(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } {
if (!v) return { cur: 0, dur: 0 };
const dur = Number.isFinite(v.duration) ? v.duration : 0;
const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0;
return { cur, dur };
}
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState();
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
const isVideo = scene?.previewAssetType === 'video';
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const paintScrub = (): void => {
const { cur, dur } = readVideoTimes(videoRef.current);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = fmt(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.classList.toggle(styles.scrubPointer, dur > 0);
scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0);
}
};
useEffect(() => {
if (!isVideo) return;
if (!assetId) return;
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
const prev = lastTargetRef.current;
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
return;
}
lastTargetRef.current = { sceneKey, assetId, autostart };
void video.dispatch({
kind: 'target.set',
assetId,
autostart,
});
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (!vp) return;
if (vp.targetAssetId !== assetId) return;
v.playbackRate = vp.playbackRate;
const desired = computeTimeSec(vp, vp.serverNowMs);
if (Number.isFinite(desired) && Math.abs(v.currentTime - desired) > 0.25) {
v.currentTime = Math.max(0, desired);
}
if (vp.playing) {
void v.play().catch(() => undefined);
} else {
v.pause();
}
paintScrub();
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
const seekByClientX = (clientX: number): void => {
const v = videoRef.current;
const { dur } = readVideoTimes(v);
if (!v || !dur || !scrubRef.current) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
const timeSec = Math.max(0, Math.min(dur, next * dur));
void video.dispatch({ kind: 'seek', timeSec });
v.currentTime = timeSec;
paintScrub();
};
return (
<div className={styles.root}>
{url && scene?.previewAssetType === 'image' ? (
<RotatedImage
url={url}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
/>
) : url && isVideo ? (
<ContainedVideo
url={url}
rotationDeg={rot}
videoRef={videoRef}
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
onTimeUpdate={paintScrub}
onLoadedMetadata={paintScrub}
>
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
</ContainedVideo>
) : (
<div className={styles.placeholder} />
)}
{isVideo ? (
<div className={styles.controls}>
<div
ref={scrubRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={0}
aria-valuenow={0}
className={[styles.scrub, styles.scrubDefault].join(' ')}
onClick={(e) => {
seekByClientX(e.clientX);
}}
onKeyDown={(e) => {
const { cur, dur } = readVideoTimes(videoRef.current);
if (!dur) return;
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
if (e.key === 'ArrowRight')
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
}}
title={t('control.scrubSeek')}
>
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.row}>
<div className={styles.transport}>
<button
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'play' })}
title={t('control.transportPlay')}
>
▶
</button>
<button
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'pause' })}
title={t('control.transportPause')}
>
❚❚
</button>
<button
type="button"
className={styles.transportBtn}
onClick={() => {
void video.dispatch({ kind: 'stop' });
paintScrub();
}}
title={t('control.transportStop')}
>
■
</button>
</div>
<div>
<span ref={curTimeRef}>0:00</span>
{' / '}
<span ref={durTimeRef}>—:—</span>
</div>
</div>
</div>
) : null}
</div>
);
}