Files
DndGamePlayer/app/renderer/shared/PresentationView.tsx
T
Ivan Fontosh c1c332364c feat(materials): free rotate session overlays and show save progress
Match NPC drag-to-rotate on the material frame during playback, and change the add/edit Save button to Saving… while import runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 14:56:42 +08:00

184 lines
6.5 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 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}
{activeMaterial ? (
<MaterialOverlay
assetId={activeMaterial.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay items={activeNpcItems} /> : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
{scene?.title ?? 'Выберите сцену в редакторе'}
</div>
</div>
) : null}
</div>
);
}