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>
  );
}
