Files
DndGamePlayer/app/renderer/shared/playlists/PlaylistsPanel.tsx
T
Ivan Fontosh 278eb78637 feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 10:37:21 +08:00

565 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'),
};
}