Files
DndGamePlayer/app/renderer/shared/PresentationView.tsx
T
Ivan Fontosh d9fbecf5a7 perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

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

188 lines
6.7 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
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 [sdState] = useSceneDarknessState();
const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState();
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 activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const project = session?.project;
const activeNpcItems =
project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
? (npcsOverlay?.activeNpcIds ?? [])
.map((id) => {
const npc = (project.npcs ?? []).find((n) => n.id === id);
if (!npc) return null;
return {
npcId: npc.id,
assetId: npc.avatarAssetId,
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
const rot = scene?.previewRotationDeg ?? 0;
useEffect(() => {
if (!scene) {
setShownImageUrl(null);
return;
}
if (scene.previewAssetType !== 'image') {
setShownImageUrl(null);
return;
}
// Show thumbnail instantly (if exists) to avoid stutter on slide switch, then swap to original when loaded.
setShownImageUrl(thumbUrl ?? originalUrl);
if (!thumbUrl || !originalUrl || thumbUrl === originalUrl) return;
let cancelled = false;
const img = new Image();
img.decoding = 'async';
img.onload = () => {
if (cancelled) return;
setShownImageUrl(originalUrl);
};
img.onerror = () => {
// keep thumbnail
};
img.src = originalUrl;
return () => {
cancelled = true;
};
}, [originalUrl, scene, thumbUrl]);
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();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
}, [
scene?.previewAssetId,
scene?.previewAssetType,
originalUrl,
vp?.revision,
vp?.targetAssetId,
vp?.playing,
vp?.playbackRate,
]);
return (
<div className={styles.root}>
{shownImageUrl && scene?.previewAssetType === 'image' ? (
<div className={styles.fill}>
<RotatedImage
url={shownImageUrl}
rotationDeg={rot}
mode="contain"
onContentRectChange={setContentRect}
/>
</div>
) : originalUrl && scene?.previewAssetType === 'video' ? (
<video
ref={videoElRef}
className={styles.video}
src={originalUrl}
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}
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null}
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
{activeMaterial ? (
<MaterialOverlay
embedded
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost>
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
{scene?.title ?? 'Выберите сцену в редакторе'}
</div>
</div>
) : null}
</div>
);
}