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
@@ -0,0 +1,287 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
clampSceneAudioVolume,
type AssetId,
type MediaAsset,
type SceneAudioRef,
} from '../../shared/types';
import { getDndApi } from '../shared/dndApi';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop';
import styles from './EditorApp.module.css';
const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref';
function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] {
if (draggedId === targetId) return refs;
const from = refs.findIndex((r) => r.assetId === draggedId);
const to = refs.findIndex((r) => r.assetId === targetId);
if (from < 0 || to < 0) return refs;
const next = [...refs];
const [item] = next.splice(from, 1);
if (!item) return refs;
next.splice(to, 0, item);
return next;
}
/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */
function AudioVolumeSlider({
assetId,
volume,
isPlaying,
previewElRef,
label,
onCommit,
}: {
assetId: AssetId;
volume: number;
isPlaying: boolean;
previewElRef: React.MutableRefObject<HTMLAudioElement | null>;
label: string;
onCommit: (volume: number) => void;
}) {
const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume));
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
setDraft(clampSceneAudioVolume(volume));
}, [assetId, volume]);
const applyLocal = (v: number) => {
const next = clampSceneAudioVolume(v);
setDraft(next);
if (isPlaying && previewElRef.current) previewElRef.current.volume = next;
};
const commit = () => {
const next = clampSceneAudioVolume(draftRef.current);
if (next === clampSceneAudioVolume(volume)) return;
onCommit(next);
};
return (
<label className={styles.audioVolumeLabel} title={label}>
<span className={styles.spanXs}>{label}</span>
<input
type="range"
className={styles.audioVolumeRange}
min={0}
max={1}
step={0.01}
value={draft}
aria-label={label}
onChange={(e) => applyLocal(Number(e.target.value))}
onPointerUp={commit}
onBlur={commit}
onKeyUp={(e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
commit();
}
}}
/>
</label>
);
}
export type EditorAudioTrackListProps = {
audioRefs: SceneAudioRef[];
mediaAssets: MediaAsset[];
removeTitle: string;
onAudioRefsChange: (next: SceneAudioRef[]) => void;
};
export function EditorAudioTrackList({
audioRefs,
mediaAssets,
removeTitle,
onAudioRefsChange,
}: EditorAudioTrackListProps) {
const { t } = useEditorI18n();
const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]);
const [playingId, setPlayingId] = useState<AssetId | null>(null);
const previewElRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
return () => {
const el = previewElRef.current;
if (el) {
el.pause();
el.src = '';
previewElRef.current = null;
}
};
}, []);
useEffect(() => {
return registerEditorAudioStopper(() => {
const el = previewElRef.current;
if (el) {
el.pause();
el.onended = null;
el.src = '';
}
previewElRef.current = null;
setPlayingId(null);
});
}, []);
const stopPreview = () => {
const el = previewElRef.current;
if (el) {
el.pause();
el.onended = null;
el.src = '';
}
previewElRef.current = null;
setPlayingId(null);
};
const togglePreview = async (assetId: AssetId, volume: number) => {
if (playingId === assetId) {
stopPreview();
return;
}
stopPreview();
try {
const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId });
const url = r.url;
if (!url) return;
const el = new Audio(url);
el.volume = clampSceneAudioVolume(volume);
el.onended = () => {
previewElRef.current = null;
setPlayingId(null);
};
previewElRef.current = el;
setPlayingId(assetId);
await el.play();
} catch {
stopPreview();
}
};
if (audioRefs.length === 0) {
return (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
);
}
return (
<div className={styles.audioList}>
{audioRefs.map((ref) => {
const asset = assetById.get(ref.assetId);
const name = asset?.originalName ?? ref.assetId;
const volume = clampSceneAudioVolume(ref.volume);
const isPlaying = playingId === ref.assetId;
return (
<div
key={ref.assetId}
className={styles.audioRow}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId;
if (!dragged) return;
const next = reorderRefs(audioRefs, dragged, ref.assetId);
if (next !== audioRefs) onAudioRefsChange(next);
}}
>
<div
className={styles.audioDragHandle}
draggable
title={name}
onDragStart={(e) => {
e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId);
e.dataTransfer.effectAllowed = 'move';
}}
>
<span className={styles.audioDragGrip} aria-hidden>
⋮⋮
</span>
<span className={styles.audioName}>{name}</span>
</div>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={ref.autoplay}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={ref.loop}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<AudioVolumeSlider
assetId={ref.assetId}
volume={volume}
isPlaying={isPlaying}
previewElRef={previewElRef}
label={t('campaign.volume')}
onCommit={(v) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, volume: v } : x,
);
onAudioRefsChange(next);
}}
/>
<button
type="button"
className={styles.audioPreviewBtn}
title={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
aria-label={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
onClick={() => void togglePreview(ref.assetId, volume)}
>
{isPlaying ? '■' : '▶'}
</button>
<button
type="button"
title={removeTitle}
className={styles.audioRemove}
onClick={() => {
if (playingId === ref.assetId) stopPreview();
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== ref.assetId));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
);
})}
</div>
);
}