fix: game audio persistence and editor perf
- Keep game/campaign audio assets referenced (no prune) - Flush pending project save on quit/switch/export to avoid losing campaignAudios - Control: prevent game music restarts on scene changes; allow always-on controls; handle autoplay-after-scene-audio - Editor: reduce ReactFlow churn with stable scene card map; lazy/async image decode - Add contract/unit tests and update test script Made-with: Cursor
This commit is contained in:
@@ -49,10 +49,18 @@ export function ControlApp() {
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const suppressNextHistoryPushRef = useRef(false);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
const audioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||
const [audioStateTick, setAudioStateTick] = useState(0);
|
||||
const audioLoadRunRef = useRef(0);
|
||||
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);
|
||||
@@ -137,6 +145,17 @@ export function ControlApp() {
|
||||
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 [];
|
||||
@@ -150,14 +169,26 @@ export function ControlApp() {
|
||||
);
|
||||
}, [project, sceneAudioRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
audioLoadRunRef.current += 1;
|
||||
const runId = audioLoadRunRef.current;
|
||||
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]);
|
||||
|
||||
const oldEls = new Map(audioElsRef.current);
|
||||
audioElsRef.current = new Map();
|
||||
audioMetaRef.current.clear();
|
||||
setAudioStateTick((x) => x + 1);
|
||||
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 };
|
||||
@@ -213,24 +244,24 @@ export function ControlApp() {
|
||||
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 (audioLoadRunRef.current !== runId) return;
|
||||
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;
|
||||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('ended', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('error', () => setAudioStateTick((x) => x + 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 });
|
||||
audioElsRef.current.set(item.assetId, el);
|
||||
sceneAudioElsRef.current.set(item.assetId, el);
|
||||
}
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
for (const { ref, el } of loaded) {
|
||||
if (audioLoadRunRef.current !== runId) {
|
||||
if (sceneAudioLoadRunRef.current !== runId) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
@@ -244,13 +275,13 @@ export function ControlApp() {
|
||||
try {
|
||||
await el.play();
|
||||
} catch {
|
||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, {
|
||||
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
sceneAudioMetaRef.current.set(ref.assetId, {
|
||||
...m,
|
||||
lastPlayError:
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
try {
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
@@ -258,7 +289,7 @@ export function ControlApp() {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||
try {
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
@@ -268,7 +299,7 @@ export function ControlApp() {
|
||||
}
|
||||
const tIn0 = performance.now();
|
||||
const tickIn = (now: number): void => {
|
||||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||
if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||
try {
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
@@ -294,19 +325,203 @@ export function ControlApp() {
|
||||
};
|
||||
}, [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:
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
});
|
||||
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 audioElsRef.current.values()) {
|
||||
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
|
||||
}, [audioStateTick]);
|
||||
}, [campaignAudioStateTick, sceneAudioStateTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyPlaying) return;
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
setCampaignAudioStateTick((x) => x + 1);
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
};
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
@@ -326,10 +541,16 @@ export function ControlApp() {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
function audioStatus(assetId: string): { label: string; detail?: string } {
|
||||
const el = audioElsRef.current.get(assetId) ?? null;
|
||||
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: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
||||
const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null };
|
||||
const meta =
|
||||
group === 'scene'
|
||||
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
|
||||
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
|
||||
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
||||
if (el.error)
|
||||
return {
|
||||
@@ -1075,13 +1296,16 @@ export function ControlApp() {
|
||||
<div className={styles.previewTitle}>Музыка</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.sectionLabel}>МУЗЫКА СЦЕНЫ</div>
|
||||
<div className={styles.spacer10} />
|
||||
{sceneAudios.length === 0 ? (
|
||||
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
||||
) : (
|
||||
) : null}
|
||||
{sceneAudios.length > 0 ? (
|
||||
<div className={styles.audioList}>
|
||||
{sceneAudios.map(({ ref, asset }) => {
|
||||
const el = audioElsRef.current.get(ref.assetId) ?? null;
|
||||
const st = audioStatus(ref.assetId);
|
||||
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;
|
||||
@@ -1106,7 +1330,7 @@ export function ControlApp() {
|
||||
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);
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!el) return;
|
||||
@@ -1114,7 +1338,7 @@ export function ControlApp() {
|
||||
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));
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
className={[
|
||||
styles.audioScrub,
|
||||
@@ -1137,15 +1361,17 @@ export function ControlApp() {
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||||
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||||
void el.play().catch(() => {
|
||||
const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, {
|
||||
const mm =
|
||||
sceneAudioMetaRef.current.get(ref.assetId) ??
|
||||
({ lastPlayError: null } as const);
|
||||
sceneAudioMetaRef.current.set(ref.assetId, {
|
||||
...mm,
|
||||
lastPlayError: 'Не удалось запустить.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -1164,7 +1390,122 @@ export function ControlApp() {
|
||||
if (!el) return;
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
setAudioStateTick((x) => x + 1);
|
||||
setSceneAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
>
|
||||
■
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.spacer12} />
|
||||
<div className={styles.sectionLabel}>МУЗЫКА ИГРЫ</div>
|
||||
<div className={styles.spacer10} />
|
||||
{campaignAudios.length === 0 ? (
|
||||
<div className={styles.musicEmpty}>В игре нет аудио.</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 ? 'Авто' : 'Ручн.'}</div>
|
||||
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||||
<div title={st.detail}>{st.label}</div>
|
||||
{!allowCampaignAudio ? <div title="В сцене есть музыка">Пауза (сцена)</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 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||||
>
|
||||
<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 ? 'Пауза: в сцене есть музыка' : 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: 'Не удалось запустить.',
|
||||
});
|
||||
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);
|
||||
}}
|
||||
>
|
||||
■
|
||||
|
||||
@@ -94,3 +94,38 @@ void test('ControlApp: радиус кисти не в блоке предпро
|
||||
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
|
||||
);
|
||||
});
|
||||
|
||||
void test('ControlApp: музыка разделена на сцену и кампанию', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('МУЗЫКА СЦЕНЫ'));
|
||||
assert.ok(src.includes('МУЗЫКА ИГРЫ'));
|
||||
// при музыке сцены — кампанию ставим на паузу
|
||||
assert.ok(src.includes('allowCampaignAudio'));
|
||||
assert.ok(
|
||||
src.includes('campaignAudioSpecKey'),
|
||||
'кампания: перезагрузка аудио привязана к списку треков, не к смене сцены',
|
||||
);
|
||||
assert.match(src, /pause campaign\./i);
|
||||
});
|
||||
|
||||
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||
const src = readControlApp();
|
||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||
const m = re.exec(src);
|
||||
assert.ok(m, 'ожидается useEffect загрузки кампании после комментария Campaign elements');
|
||||
const depList = m[1];
|
||||
assert.ok(depList !== undefined);
|
||||
const deps = depList
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
assert.deepEqual(
|
||||
deps,
|
||||
['api', 'campaignAudioSpecKey'],
|
||||
'смена сцены / allowCampaignAudio / campaignAudioRefs не должны перезапускать загрузку кампании',
|
||||
);
|
||||
assert.ok(!/\ballowCampaignAudio\b/.test(depList));
|
||||
assert.ok(!/\bcurrentScene\b/.test(depList));
|
||||
assert.ok(!/\bproject\b/.test(depList));
|
||||
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user