feat(audio): volume в проекте, DnD, параллельные плейлисты

Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-05 17:09:26 +08:00
parent dbe8ab1fda
commit 586166cacd
20 changed files with 640 additions and 239 deletions
+24 -7
View File
@@ -107,15 +107,30 @@ function clampAudioGain(v: number): number {
return Math.max(0, Math.min(1, v));
}
function readAudioGain(gains: Map<string, number>, assetId: string): number {
return gains.get(assetId) ?? 1;
function readAudioGain(gains: Map<string, number>, assetId: string, projectDefault = 1): number {
return gains.get(assetId) ?? clampAudioGain(projectDefault);
}
/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
function applyAudioGain(el: HTMLAudioElement, gains: Map<string, number>, assetId: string, factor = 1): void {
function applyAudioGain(
el: HTMLAudioElement,
gains: Map<string, number>,
assetId: string,
factor = 1,
projectDefault = 1,
): void {
if (!gains.has(assetId)) gains.set(assetId, clampAudioGain(projectDefault));
el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
}
function seedAudioGainFromRef(
gains: Map<string, number>,
assetId: string,
projectVolume: number | undefined,
): void {
if (!gains.has(assetId)) gains.set(assetId, clampAudioGain(projectVolume ?? 1));
}
/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
function lightningEffectSoundUrl(): string {
return new URL('molniya.mp3', window.location.href).href;
@@ -615,6 +630,7 @@ export function ControlApp() {
void (async () => {
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of sceneAudioRefs) {
seedAudioGainFromRef(sceneAudioGainRef.current, item.assetId, item.volume);
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
if (sceneAudioLoadRunRef.current !== runId) return;
if (!r.url) continue;
@@ -622,7 +638,7 @@ export function ControlApp() {
el.loop = item.loop;
el.preload = 'auto';
if (item.autoplay) el.volume = 0;
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId);
else applyAudioGain(el, sceneAudioGainRef.current, item.assetId, 1, item.volume);
sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
@@ -725,6 +741,7 @@ export function ControlApp() {
void (async () => {
const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
for (const item of campaignAudioRefs) {
seedAudioGainFromRef(campaignAudioGainRef.current, item.assetId, item.volume);
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
if (campaignAudioLoadRunRef.current !== runId) return;
if (!r.url) continue;
@@ -732,7 +749,7 @@ export function ControlApp() {
el.loop = item.loop;
el.preload = 'auto';
if (item.autoplay) el.volume = 0;
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId);
else applyAudioGain(el, campaignAudioGainRef.current, item.assetId, 1, item.volume);
campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
@@ -3111,7 +3128,7 @@ export function ControlApp() {
statusLabel={st.label}
{...(st.detail ? { statusDetail: st.detail } : {})}
audioEl={el}
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)}
initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId, ref.volume)}
gainMap={sceneAudioGainRef.current}
playTitle={t('control.transportPlay')}
playLabel={t('control.transportPlay')}
@@ -3189,7 +3206,7 @@ export function ControlApp() {
}
: {})}
audioEl={el}
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)}
initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId, ref.volume)}
gainMap={campaignAudioGainRef.current}
playTitle={
!allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')