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,63 @@
.root {
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
background: var(--bg0);
}
.fill {
position: absolute;
inset: 0;
}
.placeholderBg {
position: absolute;
inset: 0;
background: var(--color-overlay-dark-6);
}
.video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.vignette {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.titleWrap {
position: absolute;
left: 18px;
bottom: 16px;
right: 18px;
color: var(--text0);
}
.titleCompact {
font-size: var(--text-xl);
font-weight: 900;
letter-spacing: -0.5px;
text-shadow: var(--shadow-title);
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.titleFull {
font-size: var(--text-title-lg);
font-weight: 900;
letter-spacing: -0.5px;
text-shadow: var(--shadow-title);
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useEffect, useRef } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { useEffectsState } from './effects/useEffectsState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
export type PresentationViewProps = {
session: SessionState | null;
/** Если true — показываем укороченный заголовок/оверлей для предпросмотра. */
compact?: boolean;
showTitle?: boolean;
showEffects?: boolean;
};
export function PresentationView({
session,
compact = false,
showTitle = true,
showEffects = true,
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const previewUrl = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0;
useEffect(() => {
const el = videoElRef.current;
if (!el) return;
if (!vp) return;
if (!scene?.previewAssetId) return;
if (scene.previewAssetType !== 'video') return;
if (vp.targetAssetId !== scene.previewAssetId) return;
el.playbackRate = vp.playbackRate;
const desired = computeTimeSec(vp, vp.serverNowMs);
if (Number.isFinite(desired) && Math.abs(el.currentTime - desired) > 0.35) {
el.currentTime = Math.max(0, desired);
}
if (vp.playing) {
void el.play().catch(() => undefined);
} else {
el.pause();
}
}, [scene?.previewAssetId, scene?.previewAssetType, vp]);
return (
<div className={styles.root}>
{previewUrl && scene?.previewAssetType === 'image' ? (
<div className={styles.fill}>
<RotatedImage
url={previewUrl}
rotationDeg={rot}
mode="contain"
onContentRectChange={setContentRect}
/>
</div>
) : previewUrl && scene?.previewAssetType === 'video' ? (
<video
ref={videoElRef}
className={styles.video}
src={previewUrl}
muted
playsInline
loop={false}
preload="auto"
onError={() => {
// noop: status surfaced in control app; keep presentation clean
}}
/>
) : (
<div className={styles.placeholderBg} />
)}
<div className={styles.vignette} />
{showEffects && scene?.previewAssetType !== 'video' ? (
<PixiEffectsOverlay
state={fxState}
viewport={
contentRect
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
: undefined
}
/>
) : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
{scene?.title ?? 'Выберите сцену в редакторе'}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,15 @@
.root {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.img {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
transform-origin: center;
display: block;
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import styles from './RotatedImage.module.css';
type Mode = 'cover' | 'contain';
type RotatedImageProps = {
url: string;
rotationDeg: 0 | 90 | 180 | 270;
mode: Mode;
alt?: string;
/** Высота/ширина полностью контролируются родителем. */
style?: React.CSSProperties;
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
};
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 ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
});
ro.observe(el);
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
export function RotatedImage({
url,
rotationDeg,
mode,
alt = '',
style,
onContentRectChange,
}: RotatedImageProps) {
const [ref, size] = useElementSize<HTMLDivElement>();
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
setImgSize({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
};
img.src = url;
return () => {
cancelled = true;
};
}, [url]);
const scale = useMemo(() => {
if (!imgSize) return 1;
if (size.w <= 1 || size.h <= 1) return 1;
const rotated = rotationDeg === 90 || rotationDeg === 270;
const iw = rotated ? imgSize.h : imgSize.w;
const ih = rotated ? imgSize.w : imgSize.h;
const sx = size.w / iw;
const sy = size.h / ih;
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
}, [imgSize, mode, rotationDeg, size.h, size.w]);
useEffect(() => {
if (!onContentRectChange) return;
if (!imgSize) return;
if (size.w <= 1 || size.h <= 1) return;
const rotated = rotationDeg === 90 || rotationDeg === 270;
// Bounding-box размеров после rotate(): при 90/270 меняются местами.
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
const x = (size.w - bw) / 2;
const y = (size.h - bh) / 2;
onContentRectChange({ x, y, w: bw, h: bh });
}, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]);
const w = imgSize ? imgSize.w * scale : undefined;
const h = imgSize ? imgSize.h * scale : undefined;
return (
<div ref={ref} className={styles.root} style={style}>
<img
alt={alt}
src={url}
className={styles.img}
style={{
width: w ?? '100%',
height: h ?? '100%',
objectFit: imgSize ? undefined : mode,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
/>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
type Props = {
className?: string | undefined;
size?: number;
title?: string;
};
/** Логотип приложения (SVG из брендинга). */
export function AppLogo({ className, size = 26, title }: Props) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={title ? undefined : true}
role={title ? 'img' : undefined}
aria-label={title}
>
<rect width="32" height="32" rx="12" fill="#8B5CF6" />
<path
d="M9.33333 17.6667C9.01146 17.6678 8.71773 17.4834 8.57879 17.193C8.43985 16.9027 8.48055 16.5583 8.68333 16.3083L16.9333 7.80833C17.0608 7.66121 17.2731 7.62194 17.4448 7.71375C17.6164 7.80556 17.7016 8.00398 17.65 8.19167L16.05 13.2083C15.9542 13.4646 15.9904 13.7516 16.1467 13.9762C16.3031 14.2007 16.5597 14.3342 16.8333 14.3333H22.6667C22.9885 14.3322 23.2823 14.5166 23.4212 14.807C23.5601 15.0973 23.5195 15.4417 23.3167 15.6917L15.0667 24.1917C14.9392 24.3388 14.7269 24.3781 14.5552 24.2862C14.3836 24.1944 14.2984 23.996 14.35 23.8083L15.95 18.7917C16.0458 18.5354 16.0096 18.2484 15.8533 18.0238C15.6969 17.7993 15.4403 17.6658 15.1667 17.6667H9.33333"
stroke="white"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+5
View File
@@ -0,0 +1,5 @@
import type { DndApi } from '../../preload/index';
export function getDndApi(): DndApi {
return window.dnd;
}
@@ -0,0 +1,12 @@
.host {
position: absolute;
inset: 0;
}
.hostInteractive {
pointer-events: auto;
}
.hostPassthrough {
pointer-events: none;
}
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('PxiEffectsOverlay: canvas не перехватывает указатель в режиме без interactive', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { EffectsEvent, EffectsState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useEffectsState(): readonly [
EffectsState | null,
{ dispatch: (event: EffectsEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<EffectsState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.effects.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.effects.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.effects.dispatch, { event });
},
},
] as const;
}
+114
View File
@@ -0,0 +1,114 @@
/* ==========================================================================
Дизайн-токены (общие для всех окон). Импортируйте через globals.css.
========================================================================== */
:root {
/* --- Цвета: фон (legacy алиасы для body/globals) --- */
--bg0: #09090b;
--bg1: #09090b;
--color-bg-0: #09090b;
--color-bg-1: #09090b;
--color-bg-black: #000;
--color-overlay-dark: rgba(0, 0, 0, 0.15);
--color-overlay-dark-2: rgba(0, 0, 0, 0.18);
--color-overlay-dark-3: rgba(0, 0, 0, 0.25);
--color-overlay-dark-4: rgba(0, 0, 0, 0.28);
--color-overlay-dark-5: rgba(0, 0, 0, 0.45);
--color-overlay-dark-6: rgba(0, 0, 0, 0.55);
--color-overlay-dark-7: rgba(0, 0, 0, 0.65);
--color-scrim: rgba(0, 0, 0, 0.45);
--color-panel: rgba(255, 255, 255, 0.04);
--color-panel-2: rgba(255, 255, 255, 0.06);
--color-panel-3: rgba(255, 255, 255, 0.05);
--color-surface-elevated: rgba(10, 10, 14, 0.96);
--color-surface-elevated-2: rgba(10, 10, 14, 0.98);
--color-surface-menu: rgba(15, 16, 22, 0.98);
--color-tooltip-bg: rgba(20, 22, 28, 0.96);
/* --- Сцены: плитки (список, граф, пульт) --- */
--scene-tile-radius: 12px;
--scene-list-selected-bg: rgba(139, 92, 146, 0.1);
--scene-list-selected-border: rgba(139, 92, 146, 0.3);
--scene-list-hover-bg: rgba(139, 92, 146, 0.08);
--graph-node-active-border: rgba(139, 92, 146, 1);
/* --- Редактор: колонки --- */
--editor-column-bg: #18181b;
/* --- Цвета: обводка --- */
--stroke: rgba(255, 255, 255, 0.08);
--stroke2: rgba(255, 255, 255, 0.12);
--stroke-2: rgba(255, 255, 255, 0.12);
--stroke-light: rgba(255, 255, 255, 0.12);
--stroke-handle: rgba(167, 139, 250, 0.9);
/* --- Цвета: текст --- */
--text0: rgba(255, 255, 255, 0.92);
--text1: rgba(255, 255, 255, 0.72);
--text2: rgba(255, 255, 255, 0.52);
--panel: rgba(255, 255, 255, 0.04);
--panel2: rgba(255, 255, 255, 0.06);
--text-on-accent: rgba(255, 255, 255, 0.98);
--text-muted-on-dark: rgba(255, 255, 255, 0.85);
--text-muted-on-dark-2: rgba(255, 255, 255, 0.9);
/* --- Цвета: акцент / бренд --- */
--accent: #7c3aed;
--accent2: #a78bfa;
--accent-border: rgba(124, 58, 237, 0.55);
--accent-border-strong: rgba(124, 58, 237, 0.85);
--accent-fill-soft: rgba(124, 58, 237, 0.1);
--accent-fill-soft-2: rgba(124, 58, 237, 0.12);
--accent-fill-solid: rgba(124, 58, 237, 0.92);
--accent-glow: rgba(124, 58, 237, 0.35);
--selection-bg: rgba(124, 58, 237, 0.35);
/* --- Цвета: опасность / ошибка --- */
--color-danger: rgba(248, 113, 113, 0.95);
--color-danger-icon: #e5484d;
/* --- Тени --- */
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
--shadow-lg: 0 18px 60px rgba(0, 0, 0, 0.55);
--shadow-xl: 0 24px 80px rgba(0, 0, 0, 0.6);
--shadow-menu: 0 12px 40px rgba(0, 0, 0, 0.55);
--shadow-tooltip: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-title: 0 4px 24px rgba(0, 0, 0, 0.65);
--shadow-start-badge: 0 4px 12px rgba(0, 0, 0, 0.35);
/* --- Скругления --- */
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
--radius-xs: 8px;
--radius-pill: 999px;
/* --- Типографика --- */
--font:
'Nimbus Sans', 'Nimbus Sans L', 'Nimbus Sans OT', 'Nimbus Sans PS', ui-sans-serif, system-ui,
-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-title-lg: 42px;
/* --- Вёрстка: сетка редактора --- */
--topbar-h: 56px;
--sidebar-w: 280px;
--inspector-w: 380px;
--gap: 16px;
--pad: 16px;
/* --- Z-index --- */
--z-menu-backdrop: 9999;
--z-modal-backdrop: 20000;
--z-modal: 20001;
--z-file-menu: 40000;
--z-tooltip: 200000;
/* --- Прочее --- */
--backdrop-blur-shell: blur(14px);
--backdrop-blur-surface: blur(18px);
}
@@ -0,0 +1,50 @@
.button {
height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--panel);
cursor: pointer;
position: relative;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.buttonPrimary {
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
}
.iconOnly {
min-width: 38px;
padding: 0 8px;
}
.tooltip {
position: fixed;
transform: translate(-50%, calc(-100% - 8px));
padding: 6px 10px;
border-radius: var(--radius-xs);
font-size: var(--text-xs);
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
background: var(--color-tooltip-bg);
border: 1px solid var(--stroke-2);
box-shadow: var(--shadow-tooltip);
pointer-events: none;
z-index: var(--z-tooltip);
white-space: nowrap;
}
.input {
height: 34px;
width: 100%;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
outline: none;
}
@@ -0,0 +1,27 @@
.root {
height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) 1fr;
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--stroke);
background: #18181b;
backdrop-filter: var(--backdrop-blur-shell);
}
.body {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
gap: 0;
padding: 0;
min-height: 0;
}
.col {
min-height: 0;
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import styles from './LayoutShell.module.css';
type Props = {
topBar: React.ReactNode;
left: React.ReactNode;
center: React.ReactNode;
right: React.ReactNode;
};
export function LayoutShell({ topBar, left, center, right }: Props) {
return (
<div className={styles.root}>
<div className={styles.topBar}>{topBar}</div>
<div className={styles.body}>
<div className={styles.col}>{left}</div>
<div className={styles.col}>{center}</div>
<div className={styles.col}>{right}</div>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
.root {
border-radius: var(--radius-lg);
background: var(--color-panel-2);
border: 1px solid var(--stroke);
box-shadow: var(--shadow);
backdrop-filter: var(--backdrop-blur-surface);
overflow: hidden;
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import styles from './Surface.module.css';
type Props = {
children: React.ReactNode;
/** Разрешено `undefined` из CSS-модулей при `exactOptionalPropertyTypes`. */
className?: string | undefined;
style?: React.CSSProperties | undefined;
};
export function Surface({ children, className, style }: Props) {
return (
<div className={[styles.root, className].filter(Boolean).join(' ')} style={style}>
{children}
</div>
);
}
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('Button: тултип через портал (title), не только нативный атрибут', () => {
const src = fs.readFileSync(path.join(here, 'controls.tsx'), 'utf8');
assert.ok(src.includes('createPortal'));
assert.ok(src.includes('role="tooltip"'));
assert.ok(src.includes('onMouseEnter={showTip}'));
assert.ok(src.includes('document.body'));
});
+96
View File
@@ -0,0 +1,96 @@
import React, { useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './Controls.module.css';
type ButtonProps = {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'ghost';
disabled?: boolean;
title?: string | undefined;
/** Подпись для скринридеров (иконки без текста). */
ariaLabel?: string | undefined;
/** Компактная кнопка под одну иконку. */
iconOnly?: boolean;
};
export function Button({
children,
onClick,
variant = 'ghost',
disabled = false,
title,
ariaLabel,
iconOnly = false,
}: ButtonProps) {
const btnRef = useRef<HTMLButtonElement | null>(null);
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
const showTip = useCallback(() => {
if (disabled || !title) return;
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setTipPos({ x: r.left + r.width / 2, y: r.top });
}, [disabled, title]);
const hideTip = useCallback(() => {
setTipPos(null);
}, []);
const btnClass = [
styles.button,
variant === 'primary' ? styles.buttonPrimary : '',
iconOnly ? styles.iconOnly : '',
]
.filter(Boolean)
.join(' ');
const tip =
title && tipPos && typeof document !== 'undefined'
? createPortal(
<div role="tooltip" className={styles.tooltip} style={{ left: tipPos.x, top: tipPos.y }}>
{title}
</div>,
document.body,
)
: null;
return (
<>
<button
ref={btnRef}
type="button"
className={btnClass}
disabled={disabled}
aria-label={ariaLabel}
onClick={disabled ? undefined : onClick}
onMouseEnter={showTip}
onMouseLeave={hideTip}
onFocus={showTip}
onBlur={hideTip}
>
{children}
</button>
{tip}
</>
);
}
type InputProps = {
value: string;
placeholder?: string;
onChange: (v: string) => void;
};
export function Input({ value, placeholder, onChange }: InputProps) {
return (
<input
className={styles.input}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
+54
View File
@@ -0,0 +1,54 @@
@import '../styles/variables.css';
@import url('https://fonts.cdnfonts.com/css/nimbus-sans');
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
html {
background: var(--bg0);
}
body {
margin: 0;
font-family: var(--font);
color: var(--text0);
background: var(--bg0);
}
a {
color: inherit;
text-decoration: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border: 2px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.12);
border: 2px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
}
button,
input,
textarea {
font: inherit;
color: inherit;
}
::selection {
background: var(--selection-bg);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { AssetId } from '../../shared/types';
import { getDndApi } from './dndApi';
/**
* Возвращает `file://` URL для превью изображения. Пока загрузка или сменился id — `null`.
*/
export function useAssetUrl(assetId: AssetId | null | undefined): string | null {
const id = assetId ?? null;
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(null);
useEffect(() => {
if (id === null) {
return undefined;
}
let cancelled = false;
void getDndApi()
.invoke(ipcChannels.project.assetFileUrl, { assetId: id })
.then((r) => {
if (!cancelled) setEntry({ assetId: id, url: r.url });
});
return () => {
cancelled = true;
};
}, [id]);
if (id === null) {
return null;
}
if (entry?.assetId !== id) {
return null;
}
return entry.url;
}
/** @deprecated use `useAssetUrl` */
export function useAssetImageUrl(assetId: AssetId | null | undefined): string | null {
return useAssetUrl(assetId);
}
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useVideoPlaybackState(): readonly [
VideoPlaybackState | null,
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<VideoPlaybackState | null>(null);
const [timeOffsetMs, setTimeOffsetMs] = useState(0);
const [clientNowMs, setClientNowMs] = useState(() => Date.now());
useEffect(() => {
if (!state) return;
const id = window.setInterval(() => {
setClientNowMs(Date.now());
}, 250);
return () => window.clearInterval(id);
}, [state]);
useEffect(() => {
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
setState(r.state);
setTimeOffsetMs(r.state.serverNowMs - Date.now());
});
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
setState(next);
setTimeOffsetMs(next.serverNowMs - Date.now());
});
}, [api]);
return [
state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.video.dispatch, { event });
},
},
] as const;
}