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
+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 {