import React, { useEffect, useRef, useState } from 'react'; import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import type { SessionState } from '../../shared/ipc/contracts'; import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures'; import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay'; 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 { SceneGridOverlay } from './grid/SceneGridOverlay'; import { MaterialLegendPanel } from './materials/MaterialLegendPanel'; import { MaterialOverlay } from './materials/MaterialOverlay'; import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay'; import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay'; import { useAppPlayers } from './playerToken/useAppPlayers'; import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession'; import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { useSceneViewState } from './sceneView/useSceneViewState'; import { SceneTokensOverlay } from './tokens/SceneTokensOverlay'; import { useAppTokens } from './tokens/useAppTokens'; import { useSceneTokensSession } from './tokens/useSceneTokensSession'; import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay'; import { useSceneTrapsState } from './traps/useSceneTrapsState'; import styles from './PresentationView.module.css'; import { ContainedVideo } from './ContainedVideo'; import { RotatedImage } from './RotatedImage'; import { useAssetUrl } from './useAssetImageUrl'; import { useVideoPlaybackState } from './video/useVideoPlaybackState'; import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers'; 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 [sceneTraps] = useSceneTrapsState(); const [sceneView] = useSceneViewState(); const [materialsOverlay] = useMaterialsOverlayState(); const [npcsOverlay] = useNpcsOverlayState(); const appTokens = useAppTokens(); const appPlayersResult = useAppPlayers(); const appPlayers = USERS_BRANCH_FEATURES_ENABLED ? appPlayersResult.players : []; const [sceneTokensSession] = useSceneTokensSession(); const [sceneNpcTokensSession] = useSceneNpcTokensSession(); const [scenePlayerTokensSession] = useScenePlayerTokensSession(); const [vp] = useVideoPlaybackState(); const videoElRef = useRef(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 project = session?.project; const activeMaterialItems = project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0 ? (materialsOverlay?.activeMaterialIds ?? []) .map((id) => { const material = (project.materials ?? []).find((m) => m.id === id); if (!material) return null; return { material, layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT, legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT, }; }) .filter((x): x is NonNullable => x !== null) : []; 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 => x !== null) : []; const originalUrl = useAssetUrl(scene?.previewAssetId ?? null); const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null); const [shownImageUrl, setShownImageUrl] = useState(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 (
{shownImageUrl && scene?.previewAssetType === 'image' ? (
) : originalUrl && scene?.previewAssetType === 'video' ? (
{ // noop: status surfaced in control app; keep presentation clean }} />
) : (
)} {scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video' ? ( ) : null}
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? ( ) : null} {USERS_BRANCH_FEATURES_ENABLED && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? ( ) : null} {USERS_BRANCH_FEATURES_ENABLED && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? ( ) : null} {(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? ( ) : null} {showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? ( ) : null} {showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? ( ) : null} {showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && scene.darkenScene && contentRect ? ( ) : null} 0 || activeNpcItems.length > 0}> {activeMaterialItems.map(({ material, layout, legendLayout }) => ( {material.legend?.enabled ? ( ) : null} ))} {activeNpcItems.length > 0 ? : null} {showTitle ? (
{scene?.title ?? 'Выберите сцену в редакторе'}
) : null}
); }