perf: cut ControlApp re-renders and lazy-load VFX packs

Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 11:58:16 +08:00
parent 32a5479086
commit d9fbecf5a7
29 changed files with 1825 additions and 585 deletions
+304
View File
@@ -0,0 +1,304 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../shared/ui/controls';
import styles from './ControlApp.module.css';
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')}`;
}
function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
function VolumeSpeakerIcon({ gain }: { gain: number }) {
if (gain <= 0.001) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
/>
</svg>
);
}
if (gain < 0.5) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
);
}
export type ControlAudioCardProps = {
assetId: string;
name: string;
autoplay: boolean;
loop: boolean;
statusLabel: string;
statusDetail?: string;
extraBadge?: React.ReactNode;
audioEl: HTMLAudioElement | null;
initialGain: number;
gainMap: Map<string, number>;
playTitle: string;
playLabel: string;
pauseLabel: string;
stopLabel: string;
volumeLabel: string;
modeAutoLabel: string;
modeManualLabel: string;
loopLabel: string;
onceLabel: string;
scrubSeekLabel: string;
durationUnknownLabel: string;
/** Редкий bump родителя (play/pause/error) — не для scrub. */
onStatusChange: () => void;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
};
/**
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
*/
export function ControlAudioCard({
assetId,
name,
autoplay,
loop,
statusLabel,
statusDetail,
extraBadge,
audioEl,
initialGain,
gainMap,
playTitle,
playLabel,
pauseLabel,
stopLabel,
volumeLabel,
modeAutoLabel,
modeManualLabel,
loopLabel,
onceLabel,
scrubSeekLabel,
durationUnknownLabel,
onStatusChange,
onPlay,
onPause,
onStop,
}: ControlAudioCardProps) {
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
const onStatusChangeRef = useRef(onStatusChange);
onStatusChangeRef.current = onStatusChange;
useEffect(() => {
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
}, [assetId, gainMap, initialGain]);
useEffect(() => {
if (!audioEl) return;
let raf = 0;
const paint = (): void => {
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
}
};
const stopLoop = (): void => {
if (raf !== 0) {
window.cancelAnimationFrame(raf);
raf = 0;
}
};
const loopPaint = (): void => {
paint();
if (!audioEl.paused) {
raf = window.requestAnimationFrame(loopPaint);
} else {
raf = 0;
}
};
const startLoop = (): void => {
stopLoop();
raf = window.requestAnimationFrame(loopPaint);
};
const onPlayEv = (): void => {
startLoop();
onStatusChangeRef.current();
};
const onPauseEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onEndedEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onMetaEv = (): void => {
paint();
onStatusChangeRef.current();
};
audioEl.addEventListener('play', onPlayEv);
audioEl.addEventListener('pause', onPauseEv);
audioEl.addEventListener('ended', onEndedEv);
audioEl.addEventListener('canplay', onMetaEv);
audioEl.addEventListener('error', onMetaEv);
paint();
if (!audioEl.paused) startLoop();
return () => {
stopLoop();
audioEl.removeEventListener('play', onPlayEv);
audioEl.removeEventListener('pause', onPauseEv);
audioEl.removeEventListener('ended', onEndedEv);
audioEl.removeEventListener('canplay', onMetaEv);
audioEl.removeEventListener('error', onMetaEv);
};
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
const seekByClientX = (clientX: number): void => {
if (!audioEl || !scrubRef.current) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
const cur = audioEl.currentTime;
const pct = Math.max(0, Math.min(1, cur / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
};
const applyGain = (v: number): void => {
const g = clampAudioGain(v);
gainMap.set(assetId, g);
if (audioEl) {
try {
audioEl.volume = g;
} catch {
// ignore
}
}
setGainUi(g);
};
return (
<div className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{name}</div>
<div className={styles.audioBadges}>
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
<div>{loop ? loopLabel : onceLabel}</div>
<div title={statusDetail}>{statusLabel}</div>
{extraBadge}
</div>
<div className={styles.spacer10} />
<div
ref={scrubRef}
role="slider"
tabIndex={0}
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
onKeyDown={(e) => {
if (!audioEl) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}}
onClick={(e) => seekByClientX(e.clientX)}
>
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.timeRow}>
<div ref={curTimeRef}>0:00</div>
<div ref={durTimeRef}>:</div>
</div>
</div>
<div className={styles.audioControls}>
<div className={styles.audioTransport}>
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
</Button>
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
</Button>
<Button
title={stopLabel}
ariaLabel={stopLabel}
onClick={() => {
onStop();
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
}}
>
</Button>
</div>
<div className={styles.audioVolumeRow}>
<span className={styles.audioVolumeIcon} aria-hidden>
<VolumeSpeakerIcon gain={gainUi} />
</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={gainUi}
disabled={!audioEl}
className={styles.audioVolume}
aria-label={volumeLabel}
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
onChange={(e) => applyGain(Number(e.currentTarget.value))}
/>
</div>
</div>
</div>
);
}