Files
DndGamePlayer/app/renderer/control/ControlApp.tsx
T
2026-05-11 22:20:14 +08:00

1525 lines
59 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import styles from './ControlApp.module.css';
import { ControlScenePreview } from './ControlScenePreview';
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность визуала молнии (мс). */
const LIGHTNING_EFFECT_MS = 180;
function formatTime(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')}`;
}
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
function lightningEffectSoundUrl(): string {
return new URL('molniya.mp3', window.location.href).href;
}
function playLightningEffectSound(): void {
try {
const el = new Audio(lightningEffectSoundUrl());
el.volume = 0.88;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
export function ControlApp() {
const api = getDndApi();
const { t } = useEditorI18n();
const tRef = useRef(t);
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const suppressNextHistoryPushRef = useRef(false);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
const sceneAudioLoadRunRef = useRef(0);
const campaignAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const campaignAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0);
const campaignAudioLoadRunRef = useRef(0);
/** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */
const campaignResumeAfterSceneRef = useRef<Map<string, boolean> | null>(null);
const allowCampaignAudioRef = useRef<boolean>(true);
const audioUnmountRef = useRef(false);
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const brushRef = useRef<{
tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser';
startN?: { x: number; y: number };
points?: { x: number; y: number; tMs: number }[];
} | null>(null);
const [draftFxTick, setDraftFxTick] = useState(0);
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
const [previewContentRect, setPreviewContentRect] = useState<{
x: number;
y: number;
w: number;
h: number;
} | null>(null);
const previewContentRectRef = useRef(previewContentRect);
previewContentRectRef.current = previewContentRect;
const previewSizeRef = useRef(previewSize);
previewSizeRef.current = previewSize;
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then((res) => {
const next: SessionState = {
project: res.project,
currentSceneId: res.project?.currentSceneId ?? null,
};
setSession(next);
historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
setHistory(historyRef.current);
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
const cur = state.project?.currentGraphNodeId ?? null;
if (!cur) return;
const arr = historyRef.current;
if (suppressNextHistoryPushRef.current) {
suppressNextHistoryPushRef.current = false;
setHistory(arr);
return;
}
// Если мы перемотались на уже существующий шаг, не дублируем его в истории.
if (arr.includes(cur)) {
setHistory(arr);
return;
}
if (arr[arr.length - 1] !== cur) {
historyRef.current = [...arr, cur];
setHistory(historyRef.current);
}
});
}, [api]);
useEffect(() => {
audioUnmountRef.current = false;
return () => {
audioUnmountRef.current = true;
};
}, []);
const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820);
useEffect(() => {
void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
}, []);
const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
useEffect(() => {
void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
}, []);
const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
useEffect(() => {
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
}, []);
const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
// Keep this memo as narrow as possible: project changes on scene switch,
// but campaign audio list/config often does not.
const campaignAudioRefs = useMemo(() => project?.campaignAudios ?? [], [project?.campaignAudios]);
const allowCampaignAudio = !sceneAudioRefs.some((a) => a.autoplay);
allowCampaignAudioRef.current = allowCampaignAudio;
const campaignAudioSpecKey = useMemo(
() =>
campaignAudioRefs.map((r) => `${r.assetId}:${r.loop ? '1' : '0'}:${r.autoplay ? '1' : '0'}`).join('|'),
[campaignAudioRefs],
);
const sceneAudios = useMemo(() => {
if (!project) return [];
return sceneAudioRefs
.map((r) => {
const a = project.assets[r.assetId];
return a?.type === 'audio' ? { ref: r, asset: a } : null;
})
.filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
Boolean(x),
);
}, [project, sceneAudioRefs]);
const campaignAudios = useMemo(() => {
if (!project) return [];
return campaignAudioRefs
.map((r) => {
const a = project.assets[r.assetId];
return a?.type === 'audio' ? { ref: r, asset: a } : null;
})
.filter((x): x is { ref: (typeof campaignAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
Boolean(x),
);
}, [campaignAudioRefs, project]);
useEffect(() => {
sceneAudioLoadRunRef.current += 1;
const runId = sceneAudioLoadRunRef.current;
const oldEls = new Map(sceneAudioElsRef.current);
sceneAudioElsRef.current = new Map();
sceneAudioMetaRef.current.clear();
setSceneAudioStateTick((x) => x + 1);
const FADE_OUT_MS = 450;
const fadeOutCtl = { raf: 0, cancelled: false };
const finishFadeOut = (): void => {
for (const el of oldEls.values()) {
try {
el.pause();
el.currentTime = 0;
el.volume = 1;
} catch {
// ignore
}
}
};
if (oldEls.size > 0) {
const startVol = new Map<string, number>();
for (const [id, el] of oldEls) {
startVol.set(id, el.volume);
}
const t0 = performance.now();
const tickOut = (now: number): void => {
if (fadeOutCtl.cancelled || audioUnmountRef.current) {
finishFadeOut();
return;
}
const u = Math.min(1, (now - t0) / FADE_OUT_MS);
for (const [id, el] of oldEls) {
try {
const v0 = startVol.get(id) ?? 1;
el.volume = v0 * (1 - u);
} catch {
// ignore
}
}
if (u < 1) {
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
} else {
finishFadeOut();
}
};
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
}
if (!project || !currentScene) {
return () => {
fadeOutCtl.cancelled = true;
window.cancelAnimationFrame(fadeOutCtl.raf);
};
}
const FADE_IN_MS = 550;
void (async () => {
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of sceneAudioRefs) {
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
if (sceneAudioLoadRunRef.current !== runId) return;
if (!r.url) continue;
const el = new Audio(r.url);
el.loop = item.loop;
el.preload = 'auto';
el.volume = item.autoplay ? 0 : 1;
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('ended', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('canplay', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('error', () => setSceneAudioStateTick((x) => x + 1));
loaded.push({ ref: item, el });
sceneAudioElsRef.current.set(item.assetId, el);
}
setSceneAudioStateTick((x) => x + 1);
for (const { ref, el } of loaded) {
if (sceneAudioLoadRunRef.current !== runId) {
try {
el.pause();
el.currentTime = 0;
el.volume = 1;
} catch {
// ignore
}
continue;
}
if (!ref.autoplay) continue;
try {
await el.play();
} catch {
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
});
setSceneAudioStateTick((x) => x + 1);
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
return;
}
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
try {
el.volume = u;
} catch {
// ignore
}
if (u < 1) window.requestAnimationFrame(tickIn);
};
window.requestAnimationFrame(tickIn);
}
})();
return () => {
fadeOutCtl.cancelled = true;
window.cancelAnimationFrame(fadeOutCtl.raf);
};
}, [api, currentScene, project, sceneAudioRefs]);
// Campaign elements: lifecycle depends only on campaign track list/config, not scene or allowCampaignAudio
// (scene music uses allowCampaignAudioRef + separate pause/resume effect).
// Spec is encoded in campaignAudioSpecKey; campaignAudioRefs is intentionally omitted to avoid scene-switch churn.
useEffect(() => {
campaignAudioLoadRunRef.current += 1;
const runId = campaignAudioLoadRunRef.current;
const oldEls = new Map(campaignAudioElsRef.current);
campaignAudioElsRef.current = new Map();
campaignAudioMetaRef.current.clear();
setCampaignAudioStateTick((x) => x + 1);
for (const el of oldEls.values()) {
try {
el.pause();
el.volume = 1;
} catch {
// ignore
}
}
if (campaignAudioSpecKey === '') {
return;
}
void (async () => {
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of campaignAudioRefs) {
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
if (campaignAudioLoadRunRef.current !== runId) return;
if (!r.url) continue;
const el = new Audio(r.url);
el.loop = item.loop;
el.preload = 'auto';
el.volume = item.autoplay ? 0 : 1;
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('ended', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('canplay', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('error', () => setCampaignAudioStateTick((x) => x + 1));
loaded.push({ ref: item, el });
campaignAudioElsRef.current.set(item.assetId, el);
}
setCampaignAudioStateTick((x) => x + 1);
if (!allowCampaignAudioRef.current) return;
for (const { ref, el } of loaded) {
if (campaignAudioLoadRunRef.current !== runId) return;
if (!ref.autoplay) continue;
try {
await el.play();
} catch {
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
});
setCampaignAudioStateTick((x) => x + 1);
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
try {
el.volume = 1;
} catch {
// ignore
}
return;
}
const u = Math.min(1, (now - tIn0) / 550);
try {
el.volume = u;
} catch {
// ignore
}
if (u < 1) window.requestAnimationFrame(tickIn);
};
window.requestAnimationFrame(tickIn);
}
})();
// Deps: api + campaignAudioSpecKey only; list iteration uses current campaignAudioRefs (stable while spec is stable).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api, campaignAudioSpecKey]);
useEffect(() => {
if (allowCampaignAudio) {
const snap = campaignResumeAfterSceneRef.current;
campaignResumeAfterSceneRef.current = null;
if (snap && snap.size > 0) {
void (async () => {
for (const [assetId, wasPlaying] of snap) {
if (!wasPlaying) continue;
const el = campaignAudioElsRef.current.get(assetId) ?? null;
if (!el) continue;
try {
// If a track was created with autoplay volume ramp but never started yet,
// ensure it is audible on resume.
if (el.volume === 0) el.volume = 1;
await el.play();
} catch {
// ignore; user can press play
}
}
setCampaignAudioStateTick((x) => x + 1);
})();
}
// If we entered a scene that allows campaign audio and there was no "resume snapshot"
// (e.g. first scene had autoplay scene music so campaign autoplay was blocked),
// start campaign tracks that have autoplay enabled.
if (!snap || snap.size === 0) {
void (async () => {
for (const ref of campaignAudioRefs) {
if (!ref.autoplay) continue;
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
if (!el) continue;
if (!el.paused) continue;
try {
el.volume = 0;
} catch {
// ignore
}
try {
await el.play();
} catch {
// ignore; user can press play
continue;
}
const tIn0 = performance.now();
const tickIn = (now: number): void => {
if (!allowCampaignAudioRef.current || audioUnmountRef.current) return;
const u = Math.min(1, (now - tIn0) / 550);
try {
el.volume = u;
} catch {
// ignore
}
if (u < 1) window.requestAnimationFrame(tickIn);
};
window.requestAnimationFrame(tickIn);
}
setCampaignAudioStateTick((x) => x + 1);
})();
}
return;
}
// Scene has its own audio: remember what was playing, then pause campaign. (keep currentTime)
const snap = new Map<string, boolean>();
for (const [assetId, el] of campaignAudioElsRef.current) {
snap.set(assetId, !el.paused);
}
campaignResumeAfterSceneRef.current = snap;
for (const el of campaignAudioElsRef.current.values()) {
try {
el.pause();
} catch {
// ignore
}
}
setCampaignAudioStateTick((x) => x + 1);
// Intentionally not depending on campaignAudioRefs: this effect is about scene-driven pausing/resuming.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowCampaignAudio]);
const anyPlaying = useMemo(() => {
for (const el of sceneAudioElsRef.current.values()) {
if (!el.paused) return true;
}
for (const el of campaignAudioElsRef.current.values()) {
if (!el.paused) return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [campaignAudioStateTick, sceneAudioStateTick]);
useEffect(() => {
if (!anyPlaying) return;
let raf = 0;
const tick = () => {
setSceneAudioStateTick((x) => x + 1);
setCampaignAudioStateTick((x) => x + 1);
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
}, [anyPlaying]);
useEffect(() => {
const host = previewHostRef.current;
if (!host) return;
const update = () => {
const r = host.getBoundingClientRect();
setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
};
update();
const ro = new ResizeObserver(update);
ro.observe(host);
return () => ro.disconnect();
}, []);
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
const el =
group === 'scene'
? (sceneAudioElsRef.current.get(assetId) ?? null)
: (campaignAudioElsRef.current.get(assetId) ?? null);
if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') };
const meta =
group === 'scene'
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError };
if (el.error)
return {
label: t('control.audioError'),
detail: t('control.audioMediaError', { code: String(el.error.code) }),
};
if (el.readyState < 2) return { label: t('control.audioLoading') };
if (!el.paused) return { label: t('control.audioPlaying') };
if (el.currentTime > 0) return { label: t('control.audioPaused') };
return { label: t('control.audioStopped') };
}
const nextScenes = useMemo(() => {
if (!project) return [];
if (!currentGraphNodeId) return [];
const outgoing = project.sceneGraphEdges
.filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
.map((e) => {
const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
})
.filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
return outgoing
.map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
}, [currentGraphNodeId, project]);
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
const toolRef = useRef(tool);
toolRef.current = tool;
function layoutBrushCursor(): void {
const el = brushCursorElRef.current;
const p = cursorPosRef.current;
const cr = previewContentRectRef.current;
const ps = previewSizeRef.current;
const t = toolRef.current;
if (!el) return;
if (!p) {
el.style.visibility = 'hidden';
return;
}
el.style.visibility = 'visible';
const ox = cr ? cr.x : 0;
const oy = cr ? cr.y : 0;
const cw = cr ? cr.w : ps.w;
const ch = cr ? cr.h : ps.h;
const minDim = Math.min(cw, ch);
const size = Math.max(2, t.radiusN * minDim * 2);
el.style.left = `${String(ox + p.x * cw)}px`;
el.style.top = `${String(oy + p.y * ch)}px`;
el.style.width = `${String(size)}px`;
el.style.height = `${String(size)}px`;
}
function scheduleDraftRepaint(): void {
if (draftPaintRafRef.current !== 0) return;
draftPaintRafRef.current = requestAnimationFrame(() => {
draftPaintRafRef.current = 0;
setDraftFxTick((x) => x + 1);
});
}
useLayoutEffect(() => {
layoutBrushCursor();
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
useEffect(() => {
return () => {
if (draftPaintRafRef.current !== 0) {
cancelAnimationFrame(draftPaintRafRef.current);
}
};
}, []);
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
const host = previewHostRef.current;
if (!host) return null;
const r = host.getBoundingClientRect();
const cr = previewContentRectRef.current;
const ox = cr ? cr.x : 0;
const oy = cr ? cr.y : 0;
const cw = cr ? cr.w : r.width;
const ch = cr ? cr.h : r.height;
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
}
async function commitStroke(): Promise<void> {
if (isVideoPreviewScene) {
brushRef.current = null;
setDraftFxTick((x) => x + 1);
return;
}
if (!fxState) return;
const b = brushRef.current;
if (!b) return;
const createdAtMs = Date.now();
const seed = Math.floor(Math.random() * 1_000_000_000);
if (b.tool === 'fog' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `fog_${String(createdAtMs)}_${String(seed)}`,
type: 'fog',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
lifetimeMs: null,
},
});
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `fire_${String(createdAtMs)}_${String(seed)}`,
type: 'fire',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
// Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами.
opacity: Math.max(0.12, Math.min(0.95, tool.intensity)),
lifetimeMs: null,
},
});
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `rain_${String(createdAtMs)}_${String(seed)}`,
type: 'rain',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
lifetimeMs: null,
},
});
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `water_${String(createdAtMs)}_${String(seed)}`,
type: 'water',
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)),
lifetimeMs: null,
},
});
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const end = { x: last.x, y: last.y };
const start = { x: end.x, y: 0 };
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `lt_${String(createdAtMs)}_${String(seed)}`,
type: 'lightning',
seed,
createdAtMs,
start,
end,
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
lifetimeMs: LIGHTNING_EFFECT_MS,
},
});
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `sc_${String(createdAtMs)}_${String(seed)}`,
type: 'scorch',
seed: seed ^ 0x5a5a5a,
createdAtMs,
at: end,
radiusN: Math.max(0.03, tool.radiusN * 0.625),
opacity: 0.92,
lifetimeMs: 60_000,
},
});
playLightningEffectSound();
}
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const end = { x: last.x, y: last.y };
const start = { x: end.x, y: 0 };
const sunbeamLifeMs = await getSunbeamEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `sb_${String(createdAtMs)}_${String(seed)}`,
type: 'sunbeam',
seed,
createdAtMs,
start,
end,
widthN: Math.max(0.012, tool.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
lifetimeMs: sunbeamLifeMs,
},
});
playSunbeamEffectSound();
}
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const at = { x: last.x, y: last.y };
const poisonLifeMs = await getPoisonCloudEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `pc_${String(createdAtMs)}_${String(seed)}`,
type: 'poisonCloud',
seed,
createdAtMs,
at,
radiusN: Math.max(0.03, tool.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
lifetimeMs: poisonLifeMs,
},
});
void playPoisonCloudEffectSound(poisonLifeMs);
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return;
const at = { x: last.x, y: last.y };
const freezeLifeMs = await getFreezeEffectLifeMs();
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `fr_${String(createdAtMs)}_${String(seed)}`,
type: 'freeze',
seed,
createdAtMs,
at,
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
// Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
lifetimeMs: freezeLifeMs,
},
});
await fx.dispatch({
kind: 'instance.add',
instance: {
id: `ice_${String(createdAtMs)}_${String(seed)}`,
type: 'ice',
seed: seed ^ 0x33cc99,
createdAtMs,
at,
radiusN: Math.max(0.03, tool.radiusN * 0.9),
opacity: 0.85,
lifetimeMs: null,
},
});
playFreezeEffectSound();
}
brushRef.current = null;
setDraftFxTick((x) => x + 1);
}
const draftInstance = useMemo(() => {
const b = brushRef.current;
if (!b) return null;
const seed = 12345;
const createdAtMs = Date.now();
if (b.tool === 'fog' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fog' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)),
lifetimeMs: null,
};
}
if (b.tool === 'fire' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'fire' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'rain' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'rain' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)),
lifetimeMs: null,
};
}
if (b.tool === 'water' && b.points && b.points.length > 0) {
return {
id: '__draft__',
type: 'water' as const,
seed,
createdAtMs,
points: b.points,
radiusN: tool.radiusN,
opacity: Math.max(0.06, Math.min(0.55, tool.intensity * 0.72)),
lifetimeMs: null,
};
}
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'lightning' as const,
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.01, tool.radiusN * 0.9),
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
lifetimeMs: LIGHTNING_EFFECT_MS,
};
}
if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'sunbeam' as const,
seed,
createdAtMs,
start: { x: last.x, y: 0 },
end: { x: last.x, y: last.y },
widthN: Math.max(0.012, tool.radiusN * 0.95),
intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
lifetimeMs: sunbeamDraftLifeMs,
};
}
if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'poisonCloud' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
radiusN: Math.max(0.03, tool.radiusN * 0.95),
intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
lifetimeMs: poisonDraftLifeMs,
};
}
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
const last = b.points[b.points.length - 1];
if (last === undefined) return null;
return {
id: '__draft__',
type: 'freeze' as const,
seed,
createdAtMs,
at: { x: last.x, y: last.y },
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
lifetimeMs: freezeDraftLifeMs,
};
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
draftFxTick,
freezeDraftLifeMs,
poisonDraftLifeMs,
sunbeamDraftLifeMs,
tool.intensity,
tool.radiusN,
tool.tool,
]);
const fxMergedState = useMemo(() => {
if (!fxState) return null;
if (!draftInstance) return fxState;
return { ...fxState, instances: [...fxState.instances, draftInstance] };
}, [draftInstance, fxState]);
return (
<div className={styles.page}>
<Surface className={styles.remote}>
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
<>
<div className={styles.sectionLabel}>{t('control.effects')}</div>
<div className={styles.spacer8} />
<div className={styles.effectsStack}>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>{t('control.tools')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
iconOnly
title={t('control.eraser')}
ariaLabel={t('control.eraser')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
>
<span className={styles.iconGlyph}>🧹</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.clearEffects')}
ariaLabel={t('control.clearEffects')}
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
>
<span className={styles.clearIcon}>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
<line
x1="7"
y1="17"
x2="17"
y2="7"
stroke="#e5484d"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
</Button>
</div>
</div>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>{t('control.fieldEffects')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
iconOnly
title={t('control.fog')}
ariaLabel={t('control.fog')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
>
<span className={styles.iconGlyph}>🌫</span>
</Button>
<Button
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
iconOnly
title={t('control.rain')}
ariaLabel={t('control.rain')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
>
<span className={styles.iconGlyph}>🌧</span>
</Button>
<Button
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
iconOnly
title={t('control.fire')}
ariaLabel={t('control.fire')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
>
<span className={styles.iconGlyph}>🔥</span>
</Button>
<Button
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
iconOnly
title={t('control.water')}
ariaLabel={t('control.water')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'water' } })}
>
<span className={styles.iconGlyph}>💧</span>
</Button>
</div>
</div>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>{t('control.actionEffects')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
iconOnly
title={t('control.lightning')}
ariaLabel={t('control.lightning')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })
}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
iconOnly
title={t('control.sunbeam')}
ariaLabel={t('control.sunbeam')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'sunbeam' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
iconOnly
title={t('control.freeze')}
ariaLabel={t('control.freeze')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
>
<span className={styles.iconGlyph}></span>
</Button>
<Button
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
iconOnly
title={t('control.poisonCloud')}
ariaLabel={t('control.poisonCloud')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
}
>
<span className={styles.iconGlyph}></span>
</Button>
</div>
</div>
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
<input
type="range"
min={0.015}
max={0.18}
step={0.001}
value={tool.radiusN}
onChange={(e) => {
const v = Number((e.currentTarget as HTMLInputElement).value);
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
}}
className={styles.range}
aria-label={t('control.brushRadius')}
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
</div>
</div>
<div className={styles.spacer12} />
</>
) : null}
<div className={styles.storyWrap}>
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
<div className={styles.spacer10} />
<div className={styles.storyScroll}>
{history.map((gnId, idx) => {
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
const s = gn ? project?.scenes[gn.sceneId] : undefined;
const isCurrent = gnId === project?.currentGraphNodeId;
return (
<button
type="button"
key={`${gnId}_${String(idx)}`}
disabled={!project || isCurrent}
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
.filter(Boolean)
.join(' ')}
title={project && !isCurrent ? t('control.gotoScene') : undefined}
onClick={() => {
if (!project) return;
if (isCurrent) return;
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
suppressNextHistoryPushRef.current = true;
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
}}
>
{isCurrent ? (
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
) : (
<div className={styles.historyMuted}>{t('control.passed')}</div>
)}
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
</button>
);
})}
{history.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
</div>
</Surface>
<div className={styles.rightStack}>
<Surface className={styles.surfacePad}>
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')}
</Button>
</div>
</div>
<div className={styles.spacer10} />
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
<div className={styles.spacer10} />
<div className={styles.previewFrame}>
<div ref={previewHostRef} className={styles.previewHost}>
<ControlScenePreview
session={session}
videoRef={previewVideoRef}
onContentRectChange={setPreviewContentRect}
/>
</div>
{!isVideoPreviewScene ? (
<>
<PixiEffectsOverlay
state={fxMergedState}
style={{ zIndex: 1 }}
viewport={
previewContentRect
? {
x: previewContentRect.x,
y: previewContentRect.y,
w: previewContentRect.w,
h: previewContentRect.h,
}
: undefined
}
/>
<div
ref={brushCursorElRef}
className={styles.brushCursor}
style={{ visibility: 'hidden' }}
aria-hidden
/>
<div
className={styles.brushLayer}
onPointerEnter={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
}}
onPointerLeave={() => {
cursorPosRef.current = null;
layoutBrushCursor();
}}
onPointerDown={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
if (tool.tool === 'eraser') {
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
return;
}
brushRef.current = {
tool: tool.tool,
startN: p,
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
};
setDraftFxTick((x) => x + 1);
}}
onPointerMove={(e) => {
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
layoutBrushCursor();
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
if (id) void fx.dispatch({ kind: 'instance.remove', id });
return;
}
const b = brushRef.current;
if (!b?.points) return;
const last = b.points[b.points.length - 1];
if (!last) return;
const dx = p.x - last.x;
const dy = p.y - last.y;
const minStep = Math.max(0.004, tool.radiusN * 0.25);
if (dx * dx + dy * dy < minStep * minStep) return;
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
scheduleDraftRepaint();
}}
onPointerUp={() => {
void commitStroke();
}}
onPointerCancel={() => {
brushRef.current = null;
setDraftFxTick((x) => x + 1);
}}
/>
</>
) : null}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>{t('control.branches')}</div>
<div className={styles.branchGrid}>
{nextScenes.map((o, i) => (
<div key={o.graphNodeId} className={styles.branchCard}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: String(i + 1) })}</div>
</div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
<Button
variant="primary"
onClick={() =>
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
}
>
{t('control.switchScene')}
</Button>
</div>
))}
{nextScenes.length === 0 ? (
<div className={styles.branchEmpty}>
<div>{t('control.noBranches')}</div>
<Button
variant="primary"
disabled={!session?.project?.currentGraphNodeId}
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
>
{t('control.endPresentation')}
</Button>
</div>
) : null}
</div>
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.musicHeader}>
<div className={styles.previewTitle}>{t('control.music')}</div>
</div>
<div className={styles.spacer10} />
<div className={styles.sectionLabel}>{t('control.sceneMusic')}</div>
<div className={styles.spacer10} />
{sceneAudios.length === 0 ? (
<div className={styles.musicEmpty}>{t('control.noSceneAudio')}</div>
) : null}
{sceneAudios.length > 0 ? (
<div className={styles.audioList}>
{sceneAudios.map(({ ref, asset }) => {
const el = sceneAudioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus('scene', ref.assetId);
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
return (
<div key={ref.assetId} className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
</div>
<div className={styles.spacer10} />
<div
role="slider"
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
tabIndex={0}
onKeyDown={(e) => {
if (!el) return;
if (!dur) return;
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
setSceneAudioStateTick((x) => x + 1);
}}
onClick={(e) => {
if (!el) return;
if (!dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
el.currentTime = Math.max(0, Math.min(dur, next * dur));
setSceneAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<div
className={styles.scrubFill}
style={{ width: `${String(Math.round(pct * 100))}%` }}
/>
</div>
<div className={styles.timeRow}>
<div>{formatTime(cur)}</div>
<div>{dur ? formatTime(dur) : '—:—'}</div>
</div>
</div>
<div className={styles.audioTransport}>
<Button
variant="primary"
onClick={() => {
if (!el) return;
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
void el.play().catch(() => {
const mm =
sceneAudioMetaRef.current.get(ref.assetId) ??
({ lastPlayError: null } as const);
sceneAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: t('control.playFailed'),
});
setSceneAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setSceneAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
);
})}
</div>
) : null}
<div className={styles.spacer12} />
<div className={styles.sectionLabel}>{t('control.gameMusic')}</div>
<div className={styles.spacer10} />
{campaignAudios.length === 0 ? (
<div className={styles.musicEmpty}>{t('control.noGameAudio')}</div>
) : (
<div className={styles.audioList}>
{campaignAudios.map(({ ref, asset }) => {
const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
const st = audioStatus('campaign', ref.assetId);
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
return (
<div key={ref.assetId} className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
{!allowCampaignAudio ? (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
) : null}
</div>
<div className={styles.spacer10} />
<div
role="slider"
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
tabIndex={0}
onKeyDown={(e) => {
if (!el) return;
if (!dur) return;
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
setCampaignAudioStateTick((x) => x + 1);
}}
onClick={(e) => {
if (!el) return;
if (!dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
el.currentTime = Math.max(0, Math.min(dur, next * dur));
setCampaignAudioStateTick((x) => x + 1);
}}
className={[
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<div
className={styles.scrubFill}
style={{ width: `${String(Math.round(pct * 100))}%` }}
/>
</div>
<div className={styles.timeRow}>
<div>{formatTime(cur)}</div>
<div>{dur ? formatTime(dur) : '—:—'}</div>
</div>
</div>
<div className={styles.audioTransport}>
<Button
variant="primary"
title={!allowCampaignAudio ? t('control.pauseCampaignTitle') : undefined}
onClick={() => {
if (!el) return;
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
// If this track was created for autoplay but autoplay was blocked (e.g. scene music),
// it might still be at volume 0. Ensure manual play is audible.
try {
if (el.volume === 0) el.volume = 1;
} catch {
// ignore
}
void el.play().catch(() => {
const mm =
campaignAudioMetaRef.current.get(ref.assetId) ??
({ lastPlayError: null } as const);
campaignAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: t('control.playFailed'),
});
setCampaignAudioStateTick((x) => x + 1);
});
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
}}
>
</Button>
<Button
onClick={() => {
if (!el) return;
el.pause();
el.currentTime = 0;
setCampaignAudioStateTick((x) => x + 1);
}}
>
</Button>
</div>
</div>
);
})}
</div>
)}
</Surface>
</div>
</div>
);
}