586166cacd
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии. Co-authored-by: Cursor <cursoragent@cursor.com>
288 lines
8.9 KiB
TypeScript
288 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|