feat(audio): volume в проекте, DnD, параллельные плейлисты
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user