DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
@@ -0,0 +1,181 @@
import React, { useEffect, useMemo, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
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>;
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')}`;
}
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
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 [tick, setTick] = useState(0);
const dur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.duration) ? v.duration : 0;
},
// tick: перечитываем duration из video ref на каждом кадре RAF
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const cur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
useEffect(() => {
if (!isVideo) return;
let raf = 0;
const loop = () => {
setTick((x) => x + 1);
raf = window.requestAnimationFrame(loop);
};
raf = window.requestAnimationFrame(loop);
return () => window.cancelAnimationFrame(raf);
}, [isVideo]);
useEffect(() => {
if (!isVideo) return;
void video.dispatch({
kind: 'target.set',
assetId,
autostart: scene.previewVideoAutostart,
});
}, [assetId, isVideo, scene, 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();
}
}, [assetId, vp, videoRef]);
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
return (
<div className={styles.root}>
{url && scene?.previewAssetType === 'image' ? (
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
) : url && isVideo ? (
<video
ref={(el) => {
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
}}
className={styles.video}
src={url}
playsInline
preload="auto"
>
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
</video>
) : (
<div className={styles.placeholder} />
)}
{isVideo ? (
<div className={styles.controls}>
<div
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
className={scrubClass}
onClick={(e) => {
const v = videoRef.current;
if (!v || !dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
setTick((x) => x + 1);
}}
onKeyDown={(e) => {
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') setTick((x) => x + 1);
}}
title="Клик — перемотка"
>
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
</div>
<div className={styles.row}>
<div className={styles.transport}>
<button
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'play' })}
title="Play"
>
</button>
<button
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'pause' })}
title="Pause"
>
</button>
<button
type="button"
className={styles.transportBtn}
onClick={() => {
void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1);
}}
title="Stop"
>
</button>
</div>
<div>
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
</div>
</div>
</div>
) : null}
</div>
);
}