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
@@ -87,6 +87,16 @@
border: 1px solid var(--scene-list-selected-border);
}
.playlistRowPlaying {
box-shadow: inset 2px 0 0 var(--accent2);
}
.playlistPlayingMark {
color: var(--accent2);
font-size: 10px;
flex-shrink: 0;
}
.trackRowMatch {
outline: 1px dashed var(--accent2);
}
+108 -84
View File
@@ -13,6 +13,7 @@ import { filterAudioFilePaths, useFileDropZone } from '../../editor/fileDrop';
import editorStyles from '../../editor/EditorApp.module.css';
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
import { getDndApi } from '../dndApi';
import { registerEditorAudioStopper } from '../audio/editorAudioStop';
import { Button, Input } from '../ui/controls';
import { PlaylistPlayerBar } from './PlaylistPlayerBar';
@@ -81,37 +82,54 @@ export function PlaylistsPanel({
}
}, [selected]);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [gain, setGain] = useState(1);
const [queuePlaylistId, setQueuePlaylistId] = useState<PlaylistId | null>(null);
const [queueIndex, setQueueIndex] = useState(0);
const queueRef = useRef<{ playlistId: PlaylistId; index: number } | null>(null);
const elsRef = useRef<Map<PlaylistId, HTMLAudioElement>>(new Map());
const queuesRef = useRef<Map<PlaylistId, number>>(new Map());
const [playerUi, setPlayerUi] = useState<
Record<PlaylistId, { index: number; playing: boolean; gain: number }>
>({});
const playerUiRef = useRef(playerUi);
playerUiRef.current = playerUi;
const playAtRef = useRef<(playlist: AppPlaylist, index: number) => Promise<void>>(async () => undefined);
const statePlaylistsRef = useRef(state.playlists);
statePlaylistsRef.current = state.playlists;
const queuePlaylist = useMemo(
() => (queuePlaylistId ? state.playlists.find((p) => p.id === queuePlaylistId) ?? null : null),
[queuePlaylistId, state.playlists],
);
const currentTrack = queuePlaylist?.tracks[queueIndex] ?? null;
const currentAudio = currentTrack ? audioById.get(currentTrack.audioId) ?? null : null;
const stopPlayback = useCallback(() => {
const el = audioElRef.current;
if (el) {
el.pause();
el.removeAttribute('src');
el.load();
}
audioElRef.current = null;
setAudioEl(null);
setPlaying(false);
queueRef.current = null;
setQueuePlaylistId(null);
setQueueIndex(0);
const bumpPlayer = useCallback((playlistId: PlaylistId, patch: Partial<{ index: number; playing: boolean; gain: number }>) => {
setPlayerUi((prev) => {
const cur = prev[playlistId] ?? { index: 0, playing: false, gain: 1 };
return { ...prev, [playlistId]: { ...cur, ...patch } };
});
}, []);
useEffect(() => () => stopPlayback(), [stopPlayback]);
const destroyPlayer = useCallback((playlistId: PlaylistId) => {
const el = elsRef.current.get(playlistId);
if (el) {
el.pause();
el.onended = null;
el.onplay = null;
el.onpause = null;
el.removeAttribute('src');
el.load();
elsRef.current.delete(playlistId);
}
queuesRef.current.delete(playlistId);
setPlayerUi((prev) => {
if (!(playlistId in prev)) return prev;
const next = { ...prev };
delete next[playlistId];
return next;
});
}, []);
const stopAllPlayback = useCallback(() => {
for (const id of [...elsRef.current.keys()]) destroyPlayer(id);
}, [destroyPlayer]);
useEffect(() => () => stopAllPlayback(), [stopAllPlayback]);
useEffect(() => {
if (readOnly) return undefined;
return registerEditorAudioStopper(() => stopAllPlayback());
}, [readOnly, stopAllPlayback]);
const playAt = useCallback(
async (playlist: AppPlaylist, index: number) => {
@@ -119,61 +137,57 @@ export function PlaylistsPanel({
if (!track) return;
const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId });
if (!url) return;
let el = audioElRef.current;
let el = elsRef.current.get(playlist.id);
if (!el) {
el = new Audio();
el.preload = 'auto';
audioElRef.current = el;
setAudioEl(el);
elsRef.current.set(playlist.id, el);
el.onended = () => {
const qIndex = queuesRef.current.get(playlist.id) ?? 0;
const pl = statePlaylistsRef.current.find((p) => p.id === playlist.id);
if (!pl || pl.tracks.length === 0) {
destroyPlayer(playlist.id);
return;
}
const next = (qIndex + 1) % pl.tracks.length;
void playAtRef.current(pl, next);
};
el.onplay = () => bumpPlayer(playlist.id, { playing: true });
el.onpause = () => bumpPlayer(playlist.id, { playing: false });
}
const gain = playerUiRef.current[playlist.id]?.gain ?? 1;
el.volume = gain;
el.src = url;
queueRef.current = { playlistId: playlist.id, index };
setQueuePlaylistId(playlist.id);
setQueueIndex(index);
queuesRef.current.set(playlist.id, index);
bumpPlayer(playlist.id, { index, playing: false, gain });
try {
await el.play();
setPlaying(true);
bumpPlayer(playlist.id, { index, playing: true });
} catch {
setPlaying(false);
bumpPlayer(playlist.id, { index, playing: false });
}
},
[api, gain],
[api, bumpPlayer, destroyPlayer],
);
playAtRef.current = playAt;
useEffect(() => {
const el = audioElRef.current;
if (!el) return;
const onEnded = () => {
const q = queueRef.current;
if (!q) return;
const pl = state.playlists.find((p) => p.id === q.playlistId);
if (!pl || pl.tracks.length === 0) {
stopPlayback();
return;
}
const next = (q.index + 1) % pl.tracks.length;
void playAt(pl, next);
};
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
el.addEventListener('ended', onEnded);
el.addEventListener('play', onPlay);
el.addEventListener('pause', onPause);
return () => {
el.removeEventListener('ended', onEnded);
el.removeEventListener('play', onPlay);
el.removeEventListener('pause', onPause);
};
}, [audioEl, playAt, state.playlists, stopPlayback]);
const selectedPlayer = selected ? playerUi[selected.id] : undefined;
const selectedEl = selected ? elsRef.current.get(selected.id) ?? null : null;
const selectedQueueIndex = selectedPlayer?.index ?? 0;
const selectedPlaying = Boolean(selectedPlayer?.playing);
const selectedGain = selectedPlayer?.gain ?? 1;
const selectedCurrentTrack = selected?.tracks[selectedQueueIndex] ?? null;
const selectedCurrentAudio = selectedCurrentTrack
? audioById.get(selectedCurrentTrack.audioId) ?? null
: null;
const step = (delta: number) => {
const q = queueRef.current;
const pl = q ? state.playlists.find((p) => p.id === q.playlistId) : selected;
if (!pl || pl.tracks.length === 0) return;
const base = q?.playlistId === pl.id ? q.index : 0;
const next = (base + delta + pl.tracks.length) % pl.tracks.length;
void playAt(pl, next);
if (!selected || selected.tracks.length === 0) return;
const base = playerUi[selected.id]?.index ?? 0;
const next = (base + delta + selected.tracks.length) % selected.tracks.length;
void playAt(selected, next);
};
const createPlaylist = async () => {
@@ -223,7 +237,7 @@ export function PlaylistsPanel({
if (!pendingDelete) return;
const id = pendingDelete.id;
setPendingDelete(null);
if (queueRef.current?.playlistId === id) stopPlayback();
destroyPlayer(id);
await api.invoke(ipcChannels.playlists.delete, { id });
setSelectedId((cur) => (cur === id ? null : cur));
};
@@ -252,7 +266,11 @@ export function PlaylistsPanel({
<button
key={pl.id}
type="button"
className={[styles.playlistRow, selected?.id === pl.id ? styles.playlistRowSelected : '']
className={[
styles.playlistRow,
selected?.id === pl.id ? styles.playlistRowSelected : '',
playerUi[pl.id]?.playing ? styles.playlistRowPlaying : '',
]
.filter(Boolean)
.join(' ')}
data-testid={`playlist-row-${pl.id}`}
@@ -292,6 +310,7 @@ export function PlaylistsPanel({
onClick={() => setSelectedId(pl.id)}
>
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
{playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
<span className={styles.playlistCount}>{pl.tracks.length}</span>
</button>
))
@@ -360,7 +379,9 @@ export function PlaylistsPanel({
const title = audio?.title ?? labels.untitled;
const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId);
const isCurrent =
queuePlaylistId === selected.id && queueIndex === idx && Boolean(currentTrack);
selectedPlayer?.playing === true &&
selectedQueueIndex === idx &&
Boolean(selectedCurrentTrack);
return (
<div
key={tr.id}
@@ -465,23 +486,26 @@ export function PlaylistsPanel({
)}
<PlaylistPlayerBar
title={currentAudio?.title ?? ''}
subtitle={queuePlaylist?.name}
audioEl={audioEl}
playing={playing}
gain={gain}
onGainChange={setGain}
title={selectedCurrentAudio?.title ?? ''}
subtitle={selected?.name ?? ''}
audioEl={selectedEl}
playing={selectedPlaying}
gain={selectedGain}
onGainChange={(g) => {
if (!selected) return;
const el = elsRef.current.get(selected.id);
if (el) el.volume = g;
bumpPlayer(selected.id, { gain: g });
}}
onTogglePlay={() => {
const el = audioElRef.current;
if (!selected) return;
const el = elsRef.current.get(selected.id);
if (!el) {
if (selected && selected.tracks.length > 0) void playAt(selected, 0);
if (selected.tracks.length > 0) void playAt(selected, 0);
return;
}
if (el.paused) void el.play().then(() => setPlaying(true)).catch(() => setPlaying(false));
else {
el.pause();
setPlaying(false);
}
if (el.paused) void el.play().catch(() => bumpPlayer(selected.id, { playing: false }));
else el.pause();
}}
onPrev={() => step(-1)}
onNext={() => step(1)}