feat(playlists): библиотека плейлистов приложения

CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-03 10:37:21 +08:00
parent cfb6e62331
commit 278eb78637
46 changed files with 2438 additions and 41 deletions
@@ -0,0 +1,564 @@
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 { 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 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 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);
}, []);
useEffect(() => () => stopPlayback(), [stopPlayback]);
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 = audioElRef.current;
if (!el) {
el = new Audio();
el.preload = 'auto';
audioElRef.current = el;
setAudioEl(el);
}
el.volume = gain;
el.src = url;
queueRef.current = { playlistId: playlist.id, index };
setQueuePlaylistId(playlist.id);
setQueueIndex(index);
try {
await el.play();
setPlaying(true);
} catch {
setPlaying(false);
}
},
[api, gain],
);
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 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);
};
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);
if (queueRef.current?.playlistId === id) stopPlayback();
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 : '']
.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>
<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 =
queuePlaylistId === selected.id && queueIndex === idx && Boolean(currentTrack);
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={currentAudio?.title ?? ''}
subtitle={queuePlaylist?.name}
audioEl={audioEl}
playing={playing}
gain={gain}
onGainChange={setGain}
onTogglePlay={() => {
const el = audioElRef.current;
if (!el) {
if (selected && 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);
}
}}
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'),
};
}