586166cacd
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии. Co-authored-by: Cursor <cursoragent@cursor.com>
589 lines
22 KiB
TypeScript
589 lines
22 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||
import type {
|
||
AppPlaylist,
|
||
PlaylistAudioId,
|
||
PlaylistId,
|
||
PlaylistTrackId,
|
||
} from '../../../shared/types/appPlaylists';
|
||
import { filterPlaylistsByQuery } from '../../../shared/types/appPlaylists';
|
||
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';
|
||
import styles from './PlaylistsPanel.module.css';
|
||
import { useAppPlaylists } from './useAppPlaylists';
|
||
|
||
const TRACK_DND = 'application/x-dnd-playlist-track';
|
||
const PLAYLIST_DND = 'application/x-dnd-playlist-id';
|
||
|
||
export type PlaylistsPanelLabels = {
|
||
searchPlaceholder: string;
|
||
create: string;
|
||
rename: string;
|
||
duplicate: string;
|
||
delete: string;
|
||
addAudio: string;
|
||
emptyPlaylists: string;
|
||
emptyTracks: string;
|
||
untitled: string;
|
||
play: string;
|
||
pause: string;
|
||
prev: string;
|
||
next: string;
|
||
volume: string;
|
||
scrub: string;
|
||
emptyPlayer: string;
|
||
playPlaylist: string;
|
||
removeTrack: string;
|
||
renameAudio: string;
|
||
dropAudioHint: string;
|
||
};
|
||
|
||
export function PlaylistsPanel({
|
||
labels,
|
||
className,
|
||
readOnly = false,
|
||
}: {
|
||
labels: PlaylistsPanelLabels;
|
||
className?: string;
|
||
/** Electron player window: browse + play only (no CRUD / DnD). */
|
||
readOnly?: boolean;
|
||
}) {
|
||
const { t } = useEditorI18n();
|
||
const api = getDndApi();
|
||
const state = useAppPlaylists();
|
||
const [query, setQuery] = useState('');
|
||
const [selectedId, setSelectedId] = useState<PlaylistId | null>(null);
|
||
const [editingName, setEditingName] = useState('');
|
||
const [editingAudioId, setEditingAudioId] = useState<PlaylistAudioId | null>(null);
|
||
const [editingAudioTitle, setEditingAudioTitle] = useState('');
|
||
const [pendingDelete, setPendingDelete] = useState<AppPlaylist | null>(null);
|
||
|
||
const audioById = useMemo(() => new Map(state.audios.map((a) => [a.id, a])), [state.audios]);
|
||
const filtered = useMemo(() => filterPlaylistsByQuery(state, query), [state, query]);
|
||
const selected = useMemo(
|
||
() => state.playlists.find((p) => p.id === selectedId) ?? filtered.playlists[0] ?? null,
|
||
[filtered.playlists, selectedId, state.playlists],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (selected) {
|
||
setSelectedId(selected.id);
|
||
setEditingName(selected.name);
|
||
} else {
|
||
setEditingName('');
|
||
}
|
||
}, [selected]);
|
||
|
||
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 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 } };
|
||
});
|
||
}, []);
|
||
|
||
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) => {
|
||
const track = playlist.tracks[index];
|
||
if (!track) return;
|
||
const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId });
|
||
if (!url) return;
|
||
|
||
let el = elsRef.current.get(playlist.id);
|
||
if (!el) {
|
||
el = new Audio();
|
||
el.preload = 'auto';
|
||
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;
|
||
queuesRef.current.set(playlist.id, index);
|
||
bumpPlayer(playlist.id, { index, playing: false, gain });
|
||
try {
|
||
await el.play();
|
||
bumpPlayer(playlist.id, { index, playing: true });
|
||
} catch {
|
||
bumpPlayer(playlist.id, { index, playing: false });
|
||
}
|
||
},
|
||
[api, bumpPlayer, destroyPlayer],
|
||
);
|
||
playAtRef.current = playAt;
|
||
|
||
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) => {
|
||
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 () => {
|
||
const { playlist } = await api.invoke(ipcChannels.playlists.create, {});
|
||
setSelectedId(playlist.id);
|
||
};
|
||
|
||
const saveRename = async () => {
|
||
if (!selected) return;
|
||
if (editingName.trim() === selected.name) return;
|
||
await api.invoke(ipcChannels.playlists.rename, { id: selected.id, name: editingName });
|
||
};
|
||
|
||
const addAudio = async () => {
|
||
if (!selected) return;
|
||
const { filePaths } = await api.invoke(ipcChannels.playlists.pickAudioFiles, {});
|
||
if (filePaths.length === 0) return;
|
||
await api.invoke(ipcChannels.playlists.addAudioFiles, {
|
||
playlistId: selected.id,
|
||
filePaths,
|
||
});
|
||
};
|
||
|
||
const addAudioFromPaths = useCallback(
|
||
(paths: string[]) => {
|
||
if (!selected || paths.length === 0) return;
|
||
void api.invoke(ipcChannels.playlists.addAudioFiles, {
|
||
playlistId: selected.id,
|
||
filePaths: paths,
|
||
});
|
||
},
|
||
[api, selected],
|
||
);
|
||
|
||
const audioFileDrop = useFileDropZone({
|
||
disabled: readOnly || !selected,
|
||
filterPaths: filterAudioFilePaths,
|
||
onDropPaths: addAudioFromPaths,
|
||
});
|
||
|
||
const deleteSelected = () => {
|
||
if (readOnly || !selected) return;
|
||
setPendingDelete(selected);
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!pendingDelete) return;
|
||
const id = pendingDelete.id;
|
||
setPendingDelete(null);
|
||
destroyPlayer(id);
|
||
await api.invoke(ipcChannels.playlists.delete, { id });
|
||
setSelectedId((cur) => (cur === id ? null : cur));
|
||
};
|
||
|
||
return (
|
||
<div className={[styles.root, className].filter(Boolean).join(' ')} data-testid="playlists-panel">
|
||
<div className={styles.sidebar}>
|
||
<div className={styles.toolbar}>
|
||
<Input
|
||
value={query}
|
||
onChange={setQuery}
|
||
placeholder={labels.searchPlaceholder}
|
||
data-testid="playlists-search"
|
||
/>
|
||
{!readOnly ? (
|
||
<Button variant="primary" onClick={() => void createPlaylist()} data-testid="playlists-create">
|
||
{labels.create}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<div className={styles.list}>
|
||
{filtered.playlists.length === 0 ? (
|
||
<div className={styles.empty}>{labels.emptyPlaylists}</div>
|
||
) : (
|
||
filtered.playlists.map((pl) => (
|
||
<button
|
||
key={pl.id}
|
||
type="button"
|
||
className={[
|
||
styles.playlistRow,
|
||
selected?.id === pl.id ? styles.playlistRowSelected : '',
|
||
playerUi[pl.id]?.playing ? styles.playlistRowPlaying : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
data-testid={`playlist-row-${pl.id}`}
|
||
draggable={!readOnly}
|
||
onDragStart={
|
||
readOnly
|
||
? undefined
|
||
: (e) => {
|
||
e.dataTransfer.setData(PLAYLIST_DND, pl.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}
|
||
}
|
||
onDragOver={
|
||
readOnly
|
||
? undefined
|
||
: (e) => {
|
||
if (e.dataTransfer.types.includes(PLAYLIST_DND)) e.preventDefault();
|
||
}
|
||
}
|
||
onDrop={
|
||
readOnly
|
||
? undefined
|
||
: (e) => {
|
||
e.preventDefault();
|
||
const dragged = e.dataTransfer.getData(PLAYLIST_DND) as PlaylistId;
|
||
if (!dragged || dragged === pl.id) return;
|
||
const ids = state.playlists.map((p) => p.id);
|
||
const from = ids.indexOf(dragged);
|
||
const to = ids.indexOf(pl.id);
|
||
if (from < 0 || to < 0) return;
|
||
const next = [...ids];
|
||
next.splice(from, 1);
|
||
next.splice(to, 0, dragged);
|
||
void api.invoke(ipcChannels.playlists.setOrder, { playlistIds: next });
|
||
}
|
||
}
|
||
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>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={[
|
||
styles.main,
|
||
!readOnly && selected && audioFileDrop.dragOver ? styles.mainDropActive : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
onDragEnter={readOnly ? undefined : audioFileDrop.onDragEnter}
|
||
onDragLeave={readOnly ? undefined : audioFileDrop.onDragLeave}
|
||
onDragOver={readOnly ? undefined : audioFileDrop.onDragOver}
|
||
onDrop={readOnly ? undefined : audioFileDrop.onDrop}
|
||
>
|
||
{!readOnly && selected && audioFileDrop.dragOver ? (
|
||
<div className={styles.dropHintOverlay}>{labels.dropAudioHint}</div>
|
||
) : null}
|
||
{selected ? (
|
||
<>
|
||
<div className={styles.mainHeader}>
|
||
{readOnly ? (
|
||
<div className={styles.playlistNameReadonly}>{selected.name || labels.untitled}</div>
|
||
) : (
|
||
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
|
||
)}
|
||
<div className={styles.mainActions}>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => void playAt(selected, 0)}
|
||
disabled={selected.tracks.length === 0}
|
||
>
|
||
{labels.playPlaylist}
|
||
</Button>
|
||
{!readOnly ? (
|
||
<>
|
||
<Button variant="ghost" onClick={() => void addAudio()}>
|
||
{labels.addAudio}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => void api.invoke(ipcChannels.playlists.duplicate, { id: selected.id })}
|
||
>
|
||
{labels.duplicate}
|
||
</Button>
|
||
<Button variant="ghost" onClick={() => void deleteSelected()}>
|
||
{labels.delete}
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className={styles.tracks}>
|
||
{selected.tracks.length === 0 ? (
|
||
<div className={styles.empty}>
|
||
{labels.emptyTracks}
|
||
{!readOnly ? <div className={styles.dropHintMuted}>{labels.dropAudioHint}</div> : null}
|
||
</div>
|
||
) : (
|
||
selected.tracks.map((tr, idx) => {
|
||
const audio = audioById.get(tr.audioId);
|
||
const title = audio?.title ?? labels.untitled;
|
||
const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId);
|
||
const isCurrent =
|
||
selectedPlayer?.playing === true &&
|
||
selectedQueueIndex === idx &&
|
||
Boolean(selectedCurrentTrack);
|
||
return (
|
||
<div
|
||
key={tr.id}
|
||
className={[
|
||
styles.trackRow,
|
||
isCurrent ? styles.trackRowCurrent : '',
|
||
highlighted ? styles.trackRowMatch : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
draggable={!readOnly}
|
||
onDragStart={
|
||
readOnly
|
||
? undefined
|
||
: (e) => {
|
||
e.dataTransfer.setData(TRACK_DND, tr.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}
|
||
}
|
||
onDragOver={
|
||
readOnly
|
||
? undefined
|
||
: (e) => {
|
||
if (e.dataTransfer.types.includes(TRACK_DND)) e.preventDefault();
|
||
}
|
||
}
|
||
onDrop={
|
||
readOnly
|
||
? undefined
|
||
: (e) => {
|
||
if (!e.dataTransfer.types.includes(TRACK_DND)) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const dragged = e.dataTransfer.getData(TRACK_DND) as PlaylistTrackId;
|
||
if (!dragged || dragged === tr.id) return;
|
||
const ids = selected.tracks.map((t) => t.id);
|
||
const from = ids.indexOf(dragged);
|
||
const to = ids.indexOf(tr.id);
|
||
if (from < 0 || to < 0) return;
|
||
const next = [...ids];
|
||
next.splice(from, 1);
|
||
next.splice(to, 0, dragged);
|
||
void api.invoke(ipcChannels.playlists.setTracksOrder, {
|
||
playlistId: selected.id,
|
||
trackIds: next,
|
||
});
|
||
}
|
||
}
|
||
onDoubleClick={() => void playAt(selected, idx)}
|
||
>
|
||
<button type="button" className={styles.trackPlay} onClick={() => void playAt(selected, idx)}>
|
||
▶
|
||
</button>
|
||
{readOnly ? (
|
||
<span className={styles.trackTitleReadonly}>{title}</span>
|
||
) : editingAudioId === tr.audioId ? (
|
||
<Input
|
||
value={editingAudioTitle}
|
||
onChange={setEditingAudioTitle}
|
||
onBlur={() => {
|
||
void api.invoke(ipcChannels.playlists.renameAudio, {
|
||
audioId: tr.audioId,
|
||
title: editingAudioTitle,
|
||
});
|
||
setEditingAudioId(null);
|
||
}}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={styles.trackTitle}
|
||
onClick={() => {
|
||
setEditingAudioId(tr.audioId);
|
||
setEditingAudioTitle(title);
|
||
}}
|
||
title={labels.renameAudio}
|
||
>
|
||
{title}
|
||
</button>
|
||
)}
|
||
{!readOnly ? (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() =>
|
||
void api.invoke(ipcChannels.playlists.removeTrack, {
|
||
playlistId: selected.id,
|
||
trackId: tr.id,
|
||
})
|
||
}
|
||
>
|
||
{labels.removeTrack}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className={styles.empty}>{labels.emptyPlaylists}</div>
|
||
)}
|
||
|
||
<PlaylistPlayerBar
|
||
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={() => {
|
||
if (!selected) return;
|
||
const el = elsRef.current.get(selected.id);
|
||
if (!el) {
|
||
if (selected.tracks.length > 0) void playAt(selected, 0);
|
||
return;
|
||
}
|
||
if (el.paused) void el.play().catch(() => bumpPlayer(selected.id, { playing: false }));
|
||
else el.pause();
|
||
}}
|
||
onPrev={() => step(-1)}
|
||
onNext={() => step(1)}
|
||
playLabel={labels.play}
|
||
pauseLabel={labels.pause}
|
||
prevLabel={labels.prev}
|
||
nextLabel={labels.next}
|
||
volumeLabel={labels.volume}
|
||
scrubLabel={labels.scrub}
|
||
emptyLabel={labels.emptyPlayer}
|
||
/>
|
||
</div>
|
||
{!readOnly && pendingDelete
|
||
? createPortal(
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
className={editorStyles.modalBackdrop}
|
||
onClick={() => setPendingDelete(null)}
|
||
/>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
data-testid="playlists-delete-confirm"
|
||
className={editorStyles.modalDialog}
|
||
>
|
||
<div className={editorStyles.modalHeader}>
|
||
<div className={editorStyles.modalTitle}>{t('playlists.deleteTitle')}</div>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
className={editorStyles.modalClose}
|
||
onClick={() => setPendingDelete(null)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={editorStyles.muted}>
|
||
{t('playlists.deleteConfirm', { name: pendingDelete.name })}
|
||
</div>
|
||
<div className={editorStyles.modalFooter}>
|
||
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
|
||
<Button variant="primary" onClick={() => void confirmDelete()}>
|
||
{t('common.delete')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function playlistsPanelLabelsFromT(t: (key: string) => string): PlaylistsPanelLabels {
|
||
return {
|
||
searchPlaceholder: t('playlists.search'),
|
||
create: t('playlists.create'),
|
||
rename: t('playlists.rename'),
|
||
duplicate: t('playlists.duplicate'),
|
||
delete: t('playlists.delete'),
|
||
addAudio: t('playlists.addAudio'),
|
||
emptyPlaylists: t('playlists.empty'),
|
||
emptyTracks: t('playlists.emptyTracks'),
|
||
untitled: t('playlists.untitled'),
|
||
play: t('playlists.play'),
|
||
pause: t('playlists.pause'),
|
||
prev: t('playlists.prev'),
|
||
next: t('playlists.next'),
|
||
volume: t('playlists.volume'),
|
||
scrub: t('playlists.scrub'),
|
||
emptyPlayer: t('playlists.emptyPlayer'),
|
||
playPlaylist: t('playlists.playPlaylist'),
|
||
removeTrack: t('playlists.removeTrack'),
|
||
renameAudio: t('playlists.renameAudio'),
|
||
dropAudioHint: t('playlists.dropAudioHint'),
|
||
};
|
||
}
|