feat(audio): volume в проекте, DnD, параллельные плейлисты
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/** Регистрация стопперов превью/плееров редактора — глушим при запуске игры. */
|
||||
|
||||
const stoppers = new Set<() => void>();
|
||||
|
||||
export function registerEditorAudioStopper(stop: () => void): () => void {
|
||||
stoppers.add(stop);
|
||||
return () => {
|
||||
stoppers.delete(stop);
|
||||
};
|
||||
}
|
||||
|
||||
export function stopAllEditorAudio(): void {
|
||||
for (const stop of [...stoppers]) {
|
||||
try {
|
||||
stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user