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
+18 -120
View File
@@ -30,6 +30,7 @@ import type {
} from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { ContainedVideo } from '../shared/ContainedVideo';
import { stopAllEditorAudio } from '../shared/audio/editorAudioStop';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
@@ -38,6 +39,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import { EditorAudioTrackList } from './EditorAudioTrackList';
import {
filterAudioFilePaths,
partitionSceneMediaDrops,
@@ -366,6 +368,7 @@ export function EditorApp() {
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return;
stopAllEditorAudio();
setLaunching(true);
setRunMenuOpen(false);
setLaunchPlayersOpen(false);
@@ -508,6 +511,7 @@ export function EditorApp() {
// ignore
}
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (open) stopAllEditorAudio();
setPresentationOpen(open);
});
})();
@@ -2531,7 +2535,6 @@ function CampaignInspector({
onDropAudio,
}: CampaignInspectorProps) {
const { t } = useEditorI18n();
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
const audioDrop = useFileDropZone({
onDropPaths: onDropAudio,
filterPaths: filterAudioFilePaths,
@@ -2547,71 +2550,19 @@ function CampaignInspector({
onDrop={audioDrop.onDrop}
>
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{audioRefs.length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</div>
) : (
<>
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...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={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('campaign.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<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>
<EditorAudioTrackList
audioRefs={audioRefs}
mediaAssets={mediaAssets}
removeTitle={t('campaign.removeTitle')}
onAudioRefsChange={onAudioRefsChange}
/>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</>
)}
@@ -2651,7 +2602,6 @@ function SceneInspector({
const { t } = useEditorI18n();
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
const previewUrl = useAssetUrl(previewAssetId);
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
const descriptionEmpty = isSceneDescriptionEmpty(description);
const descriptionPreviewHtml = useMemo(
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
@@ -2835,71 +2785,19 @@ function SceneInspector({
onDrop={sceneAudioDrop.onDrop}
>
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
{audioRefs.length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</div>
) : (
<>
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...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={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('scene.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<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>
<EditorAudioTrackList
audioRefs={audioRefs}
mediaAssets={mediaAssets}
removeTitle={t('scene.removeTitle')}
onAudioRefsChange={onAudioRefsChange}
/>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</>
)}