Files
DndGamePlayer/app/renderer/shared/playlists/PlaylistsPanel.tsx
T
Ivan Fontosh a29c72b307 feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 06:21:42 +08:00

593 lines
22 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 { 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} title={pl.name || labels.untitled}>{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} title={selected.name || labels.untitled}>
{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={title}>
{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}
title={title}
onClick={() => {
setEditingAudioId(tr.audioId);
setEditingAudioTitle(title);
}}
>
{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'),
};
}