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