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
+49 -5
View File
@@ -1110,7 +1110,7 @@
.audioList {
display: grid;
gap: 6px;
max-height: 160px;
max-height: 280px;
overflow: auto;
}
@@ -1120,10 +1120,32 @@
border-radius: var(--radius-xs);
background: var(--color-overlay-dark-3);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
align-items: start;
min-width: 0;
cursor: default;
}
.audioDragHandle {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: grab;
user-select: none;
}
.audioDragHandle:active {
cursor: grabbing;
}
.audioDragGrip {
flex-shrink: 0;
color: var(--text2);
font-size: 10px;
letter-spacing: -2px;
line-height: 1;
}
.audioName {
@@ -1135,13 +1157,35 @@
.audioControls {
color: var(--text2);
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.audioVolumeLabel {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.audioVolumeRange {
width: 72px;
accent-color: var(--accent, #3b82f6);
}
.audioPreviewBtn {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0 4px;
line-height: 1;
font-size: 12px;
}
.audioRemove {
flex-shrink: 0;
min-width: 28px;
+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>
</>
)}
@@ -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>
);
}
+6
View File
@@ -11,6 +11,7 @@ import styles from './EditorApp.module.css';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
nameFromFilePath,
pickFirstMaterialImagePath,
useFileDropZone,
} from './fileDrop';
@@ -86,6 +87,11 @@ export function MaterialEditModal({
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path);
setName((prev) => {
if (prev.trim()) return prev;
const fromFile = nameFromFilePath(path);
return fromFile || prev;
});
setLocalPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return previewUrl;
+6 -2
View File
@@ -69,10 +69,14 @@ export function isPreviewMediaPath(filePath: string): boolean {
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
}
export function sceneTitleFromMediaPath(filePath: string): string {
export function nameFromFilePath(filePath: string): string {
const base = filePath.split(/[/\\]/).pop() ?? filePath;
const dot = base.lastIndexOf('.');
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
return (dot > 0 ? base.slice(0, dot) : base).trim();
}
export function sceneTitleFromMediaPath(filePath: string): string {
const title = nameFromFilePath(filePath);
return title.length > 0 ? title : 'Новая сцена';
}
@@ -42,7 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
id: sid,
title: 'T',
description: 'A',
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] },
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true, volume: 1 }] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [],
layout: { x: 0, y: 0 },
@@ -411,6 +411,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.',
'campaign.auto': 'Авто',
'campaign.loop': 'Цикл',
'campaign.volume': 'Громк.',
'campaign.preview': 'Прослушать',
'campaign.previewStop': 'Стоп',
'campaign.removeTitle': 'Убрать из кампании',
'campaign.upload': 'Загрузить',
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
@@ -1154,6 +1157,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'campaign.noFiles': 'No files yet. Add audio.',
'campaign.auto': 'Auto',
'campaign.loop': 'Loop',
'campaign.volume': 'Vol.',
'campaign.preview': 'Preview',
'campaign.previewStop': 'Stop',
'campaign.removeTitle': 'Remove from campaign',
'campaign.upload': 'Upload',
'drop.hintAudio': 'Drop audio files here',