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
+16
View File
@@ -1826,6 +1826,22 @@ export function ControlApp() {
</svg>
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.playlistsTool')}
ariaLabel={t('control.playlistsTool')}
data-testid="control-playlists-tool"
onClick={() => {
void api.invoke(ipcChannels.windows.openPlaylists, {}).catch((err) => {
console.error('[control] openPlaylists failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
🎵
</span>
</Button>
<Button
variant="ghost"
iconOnly
+29 -3
View File
@@ -60,6 +60,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal';
import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -161,6 +162,7 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
@@ -382,7 +384,10 @@ export function EditorApp() {
);
useEffect(() => {
if (presentationOpen) setLaunching(false);
if (presentationOpen) {
setLaunching(false);
setPlaylistsManagerOpen(false);
}
}, [presentationOpen]);
const currentProjectName = state.project?.meta.name ?? '';
@@ -864,6 +869,23 @@ export function EditorApp() {
{t('top.players')}
</button>
) : null}
<button
type="button"
data-testid="playlists-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlaylistsManagerOpen(true);
}}
>
{t('top.playlists')}
</button>
</div>
<div className={styles.flex1} />
{appVersionText ? (
@@ -1561,8 +1583,8 @@ export function EditorApp() {
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections, npcIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
onExport={async (projectId, selections, npcIds, playlistIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
}}
/>
<ImportSourceModal
@@ -1656,6 +1678,10 @@ export function EditorApp() {
open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)}
/>
<PlaylistsManagerModal
open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)}
/>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
@@ -0,0 +1,14 @@
.dialog {
width: min(960px, calc(100vw - 48px));
height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 4px 8px;
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import {
PlaylistsPanel,
playlistsPanelLabelsFromT,
} from '../shared/playlists/PlaylistsPanel';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import panelStyles from './PlaylistsModal.module.css';
export function PlaylistsManagerModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
if (!open) return null;
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, panelStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="playlists-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('playlists.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={panelStyles.body}>
<PlaylistsPanel labels={labels} />
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+60 -23
View File
@@ -18,6 +18,7 @@ import {
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button, Select } from '../shared/ui/controls';
import { useAppPlaylists } from '../shared/playlists/useAppPlaylists';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -37,6 +38,7 @@ type ExportProjectModalProps = {
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
playlistIds: string[],
) => Promise<void>;
};
@@ -50,12 +52,14 @@ export function ExportProjectModal({
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const playlistsState = useAppPlaylists();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [selectedPlaylistIds, setSelectedPlaylistIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -67,6 +71,7 @@ export function ExportProjectModal({
setError(null);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setSelectedPlaylistIds(new Set());
setStep('storylines');
setNpcs([]);
}, [initialProjectId, open]);
@@ -156,7 +161,7 @@ export function ExportProjectModal({
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections, npcIds);
await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -168,7 +173,7 @@ export function ExportProjectModal({
const goNextFromStorylines = () => {
if (!canContinueStorylines) return;
if (npcs.length === 0) {
if (npcs.length === 0 && playlistsState.playlists.length === 0) {
runExport([]);
return;
}
@@ -248,26 +253,58 @@ export function ExportProjectModal({
</div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
{npcs.length > 0 ? (
<>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
</>
) : null}
{playlistsState.playlists.length > 0 ? (
<>
<div className={styles.fieldLabel}>{t('export.playlists')}</div>
<div className={styles.muted}>{t('export.playlistsHint')}</div>
<div className={styles.storylineChecklist}>
{playlistsState.playlists.map((pl) => (
<label key={pl.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedPlaylistIds.has(pl.id)}
disabled={saving}
onChange={() => {
setSelectedPlaylistIds((prev) => {
const next = new Set(prev);
if (next.has(pl.id)) next.delete(pl.id);
else next.add(pl.id);
return next;
});
}}
/>
<span>
{pl.name} ({pl.tracks.length})
</span>
</label>
))}
</div>
</>
) : null}
</div>
)}
@@ -289,7 +326,7 @@ export function ExportProjectModal({
disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines}
>
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
{npcs.length > 0 || playlistsState.playlists.length > 0 ? t('export.next') : t('export.saveAs')}
</Button>
) : (
<Button
@@ -111,6 +111,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.project': 'Проект',
'top.file': 'Файл',
'top.players': 'Игроки',
'top.playlists': 'Плейлисты',
'top.backToProjects': 'К списку проектов',
'top.appVersion': 'Версия приложения',
'top.run': 'Запустить',
@@ -325,6 +326,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.npcsHint':
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
'export.selectAllNpcs': 'Отметить всех',
'export.playlists': 'Плейлисты',
'export.playlistsHint': 'По умолчанию не выбраны. Отметьте плейлисты, которые нужно упаковать в архив.',
'export.next': 'Далее',
'export.back': 'Назад',
'export.exporting': 'Экспорт…',
@@ -459,6 +462,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'players.deleteTeamTitle': 'Удаление команды',
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
'playlists.title': 'Плейлисты',
'playlists.search': 'Поиск по плейлистам и трекам…',
'playlists.create': 'Создать',
'playlists.rename': 'Переименовать',
'playlists.duplicate': 'Дублировать',
'playlists.delete': 'Удалить',
'playlists.deleteTitle': 'Удаление плейлиста',
'playlists.deleteConfirm':
'Удалить плейлист «{name}»? Если он сейчас играет — воспроизведение остановится.',
'playlists.addAudio': 'Добавить аудио',
'playlists.empty': 'Нет плейлистов. Создайте первый.',
'playlists.emptyTracks': 'В плейлисте пока нет треков.',
'playlists.dropAudioHint': 'Перетащите аудиофайлы сюда',
'playlists.untitled': 'Без названия',
'playlists.play': 'Играть',
'playlists.pause': 'Пауза',
'playlists.prev': 'Предыдущий',
'playlists.next': 'Следующий',
'playlists.volume': 'Громкость',
'playlists.scrub': 'Перемотка',
'playlists.emptyPlayer': 'Ничего не играет',
'playlists.playPlaylist': 'Играть плейлист',
'playlists.removeTrack': 'Убрать',
'playlists.renameAudio': 'Переименовать трек',
'npcs.open': 'НПС',
'npcs.editorTitle': 'НПС',
'npcs.add': 'Добавить',
@@ -619,6 +647,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.markerTool': 'Маркер',
'control.markerColor': 'Цвет маркера',
'control.materialsTool': 'Материалы',
'control.playlistsTool': 'Плейлисты',
'control.npcsTool': 'НПС',
'control.descriptionMissing': 'Описание отсутствует',
'control.effects': 'ЭФФЕКТЫ',
@@ -761,6 +790,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.project': 'Project',
'top.file': 'File',
'top.players': 'Players',
'top.playlists': 'Playlists',
'top.backToProjects': 'Back to projects',
'top.appVersion': 'App version',
'top.run': 'Run',
@@ -975,6 +1005,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.npcsHint':
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
'export.selectAllNpcs': 'Select all',
'export.playlists': 'Playlists',
'export.playlistsHint': 'None selected by default. Check playlists to include in the archive.',
'export.next': 'Next',
'export.back': 'Back',
'export.exporting': 'Exporting…',
@@ -1111,6 +1143,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'players.deleteTeamConfirm':
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
'playlists.title': 'Playlists',
'playlists.search': 'Search playlists and tracks…',
'playlists.create': 'Create',
'playlists.rename': 'Rename',
'playlists.duplicate': 'Duplicate',
'playlists.delete': 'Delete',
'playlists.deleteTitle': 'Delete playlist',
'playlists.deleteConfirm':
'Delete playlist “{name}”? If it is playing, playback will stop.',
'playlists.addAudio': 'Add audio',
'playlists.empty': 'No playlists yet. Create one.',
'playlists.emptyTracks': 'This playlist has no tracks yet.',
'playlists.dropAudioHint': 'Drop audio files here',
'playlists.untitled': 'Untitled',
'playlists.play': 'Play',
'playlists.pause': 'Pause',
'playlists.prev': 'Previous',
'playlists.next': 'Next',
'playlists.volume': 'Volume',
'playlists.scrub': 'Seek',
'playlists.emptyPlayer': 'Nothing playing',
'playlists.playPlaylist': 'Play playlist',
'playlists.removeTrack': 'Remove',
'playlists.renameAudio': 'Rename track',
'npcs.open': 'NPCs',
'npcs.editorTitle': 'NPCs',
'npcs.add': 'Add',
@@ -1271,6 +1328,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.markerTool': 'Marker',
'control.markerColor': 'Marker color',
'control.materialsTool': 'Materials',
'control.playlistsTool': 'Playlists',
'control.npcsTool': 'NPCs',
'control.descriptionMissing': 'No description',
'control.effects': 'EFFECTS',
@@ -157,6 +157,7 @@ type Actions = {
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
playlistIds?: string[],
) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
};
@@ -901,6 +902,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
playlistIds: string[] = [],
) => {
try {
const res = await api.invoke(ipcChannels.project.exportZip, {
@@ -908,6 +910,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
storylineSelections,
npcIds,
labels,
playlistIds,
});
if (res.canceled) return;
} finally {
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/playlists/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,15 @@
.page {
height: 100vh;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
}
.panel {
flex: 1;
min-height: 0;
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useMemo } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import {
PlaylistsPanel,
playlistsPanelLabelsFromT,
} from '../shared/playlists/PlaylistsPanel';
import styles from './PlaylistsApp.module.css';
export function PlaylistsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closePlaylists, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
return (
<div className={styles.page} data-testid="playlists-app">
<PlaylistsPanel labels={labels} className={styles.panel} readOnly />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { PlaylistsApp } from './PlaylistsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Playlists">
<EditorI18nProvider>
<PlaylistsApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
@@ -0,0 +1,67 @@
.bar {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--stroke);
border-radius: var(--radius-lg);
background: var(--color-overlay-dark-2);
}
.meta {
min-width: 0;
}
.title {
font-weight: 800;
font-size: var(--text-sm);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
margin-top: 2px;
font-size: var(--text-xs);
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transport {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.scrubRow {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center;
}
.scrub {
width: 100%;
}
.time {
font-size: var(--text-xs);
color: var(--text2);
font-variant-numeric: tabular-nums;
min-width: 2.5em;
}
.volume {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
}
.volumeLabel {
font-size: var(--text-xs);
color: var(--text2);
}
@@ -0,0 +1,153 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../ui/controls';
import styles from './PlaylistPlayerBar.module.css';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function clampGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
export type PlaylistPlayerBarProps = {
title: string;
subtitle?: string;
audioEl: HTMLAudioElement | null;
playing: boolean;
gain: number;
onGainChange: (gain: number) => void;
onTogglePlay: () => void;
onPrev: () => void;
onNext: () => void;
playLabel: string;
pauseLabel: string;
prevLabel: string;
nextLabel: string;
volumeLabel: string;
scrubLabel: string;
emptyLabel: string;
};
export function PlaylistPlayerBar({
title,
subtitle,
audioEl,
playing,
gain,
onGainChange,
onTogglePlay,
onPrev,
onNext,
playLabel,
pauseLabel,
prevLabel,
nextLabel,
volumeLabel,
scrubLabel,
emptyLabel,
}: PlaylistPlayerBarProps) {
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const scrubbing = useRef(false);
useEffect(() => {
if (!audioEl) {
setCurrent(0);
setDuration(0);
return;
}
const sync = () => {
if (!scrubbing.current) setCurrent(audioEl.currentTime);
setDuration(Number.isFinite(audioEl.duration) ? audioEl.duration : 0);
};
sync();
audioEl.addEventListener('timeupdate', sync);
audioEl.addEventListener('loadedmetadata', sync);
audioEl.addEventListener('durationchange', sync);
return () => {
audioEl.removeEventListener('timeupdate', sync);
audioEl.removeEventListener('loadedmetadata', sync);
audioEl.removeEventListener('durationchange', sync);
};
}, [audioEl]);
useEffect(() => {
if (audioEl) audioEl.volume = clampGain(gain);
}, [audioEl, gain]);
const hasTrack = Boolean(audioEl);
return (
<div className={styles.bar} data-testid="playlist-player-bar">
<div className={styles.meta}>
<div className={styles.title}>{hasTrack ? title : emptyLabel}</div>
{hasTrack && subtitle ? <div className={styles.subtitle}>{subtitle}</div> : null}
</div>
<div className={styles.transport}>
<Button variant="ghost" iconOnly title={prevLabel} ariaLabel={prevLabel} disabled={!hasTrack} onClick={onPrev}>
⏮
</Button>
<Button
variant="primary"
iconOnly
title={playing ? pauseLabel : playLabel}
ariaLabel={playing ? pauseLabel : playLabel}
disabled={!hasTrack}
onClick={onTogglePlay}
data-testid="playlist-play-pause"
>
{playing ? '⏸' : '▶'}
</Button>
<Button variant="ghost" iconOnly title={nextLabel} ariaLabel={nextLabel} disabled={!hasTrack} onClick={onNext}>
⏭
</Button>
</div>
<div className={styles.scrubRow}>
<span className={styles.time}>{formatTime(current)}</span>
<input
type="range"
className={styles.scrub}
min={0}
max={Math.max(duration, 0.001)}
step={0.01}
value={Math.min(current, duration || 0)}
disabled={!hasTrack || !duration}
aria-label={scrubLabel}
onMouseDown={() => {
scrubbing.current = true;
}}
onMouseUp={(e) => {
scrubbing.current = false;
if (audioEl) audioEl.currentTime = Number(e.currentTarget.value);
}}
onChange={(e) => {
const v = Number(e.currentTarget.value);
setCurrent(v);
if (audioEl && !scrubbing.current) audioEl.currentTime = v;
}}
/>
<span className={styles.time}>{formatTime(duration)}</span>
</div>
<label className={styles.volume}>
<span className={styles.volumeLabel}>{volumeLabel}</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={clampGain(gain)}
aria-label={volumeLabel}
onChange={(e) => onGainChange(clampGain(Number(e.currentTarget.value)))}
/>
</label>
</div>
);
}
@@ -0,0 +1,152 @@
.root {
display: grid;
grid-template-columns: minmax(200px, 260px) 1fr;
gap: 12px;
min-height: 0;
flex: 1;
height: 100%;
}
.sidebar,
.main {
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.mainDropActive {
outline: 2px dashed var(--accent2);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
.dropHintOverlay {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
pointer-events: none;
background: var(--color-overlay-dark-3);
color: var(--text);
font-size: var(--text-sm);
font-weight: 700;
border-radius: var(--radius-sm);
}
.dropHintMuted {
margin-top: 8px;
color: var(--text2);
font-size: var(--text-xs);
font-weight: 400;
}
.toolbar {
display: grid;
gap: 8px;
}
.list,
.tracks {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
padding: 6px;
display: grid;
gap: 4px;
align-content: start;
}
.playlistRow,
.trackRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.playlistRow:hover,
.trackRow:hover {
background: var(--accent-fill-soft);
}
.playlistRowSelected,
.trackRowCurrent {
background: var(--scene-list-selected-bg);
border: 1px solid var(--scene-list-selected-border);
}
.trackRowMatch {
outline: 1px dashed var(--accent2);
}
.playlistName,
.trackTitle,
.playlistNameReadonly,
.trackTitleReadonly {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
}
.trackTitle {
background: transparent;
border: 0;
color: inherit;
text-align: left;
cursor: text;
padding: 0;
}
.playlistNameReadonly {
font-weight: 700;
font-size: var(--text-md, 1rem);
}
.trackTitleReadonly {
color: inherit;
}
.playlistCount {
font-size: var(--text-xs);
color: var(--text2);
}
.mainHeader {
display: grid;
gap: 8px;
}
.mainActions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.trackPlay {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
padding: 0 4px;
}
.empty {
padding: 12px;
color: var(--text2);
font-size: var(--text-sm);
}
@@ -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'),
};
}
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { PlaylistsState } from '../../../shared/types/appPlaylists';
import { getDndApi } from '../dndApi';
const EMPTY: PlaylistsState = { playlists: [], audios: [] };
export function useAppPlaylists(): PlaylistsState {
const api = getDndApi();
const [state, setState] = useState<PlaylistsState>(EMPTY);
useEffect(() => {
void api.invoke(ipcChannels.playlists.getState, {}).then(setState);
return api.on(ipcChannels.playlists.stateChanged, (next) => setState(next));
}, [api]);
return state;
}
@@ -12,6 +12,7 @@ const SECONDARY_WINDOW_MAINS = [
'npcs/npcsEditorMain.tsx',
'npcs/npcsMain.tsx',
'materials/main.tsx',
'playlists/main.tsx',
'sceneEditor/main.tsx',
'sceneDescription/main.tsx',
'control/main.tsx',
@@ -125,6 +126,20 @@ void test('EditorApp: split Run button and launch-with-players modal', () => {
assert.ok(editor.includes('top.runWithPlayers'));
});
void test('EditorApp: Playlists header and modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="playlists-header-btn"'));
assert.ok(src.includes('PlaylistsManagerModal'));
assert.ok(src.includes('setPlaylistsManagerOpen(true)'));
assert.ok(src.includes('setPlaylistsManagerOpen(false)'));
});
void test('ControlApp: Playlists tool opens window', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="control-playlists-tool"'));
assert.ok(src.includes('windows.openPlaylists'));
});
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
const css = fs.readFileSync(
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),