feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user