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
+149 -4
View File
@@ -38,6 +38,7 @@ import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { PlaylistsStore } from './playlists/playlistsStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
@@ -65,6 +66,7 @@ import {
isMultiWindowOpen,
markAppQuitting,
openMaterialsWindow,
openPlaylistsWindow,
openMultiWindow,
openNpcsEditorWindow,
openSceneEditorWindow,
@@ -72,6 +74,7 @@ import {
openNpcsWindow,
openSceneDescriptionWindow,
closeMaterialsWindow,
closePlaylistsWindow,
closeNpcsEditorWindow,
closeSceneEditorWindow,
closeTokenPathEditorWindow,
@@ -83,6 +86,7 @@ import {
warmNpcsEditorWindow,
} from './windows/createWindows';
import { listDisplayInfos } from './windows/presentationDisplay';
import { asPlaylistId } from '../shared/types/ids';
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
import { tokenPathTotalLength } from '../shared/types/tokenPath';
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
@@ -181,6 +185,7 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
let playlistsStore: PlaylistsStore | null = null;
function emitTokenPathSessionState(): void {
const state = tokenPathSessionStore.getState();
@@ -317,6 +322,14 @@ function emitPlayersState(): void {
}
}
function emitPlaylistsState(): void {
if (!playlistsStore) return;
const state = playlistsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.playlists.stateChanged, state);
}
}
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.upsertProgress, p);
@@ -517,11 +530,13 @@ async function main() {
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
}
playlistsStore = new PlaylistsStore(app.getPath('userData'));
await playlistsStore.ensureLoaded();
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore, tokensStore, playersStore);
registerDndAssetProtocol(projectStore, tokensStore, playersStore, playlistsStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
@@ -623,6 +638,14 @@ async function main() {
closeMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openPlaylists, () => {
openPlaylistsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closePlaylists, () => {
closePlaylistsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
openNpcsEditorWindow();
return { ok: true };
@@ -1237,6 +1260,11 @@ async function main() {
...(p.detail ? { detail: p.detail } : null),
});
});
const cacheDir = projectStore.getOpenCacheDir();
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { canceled: false as const, project };
@@ -1295,6 +1323,10 @@ async function main() {
async (cacheDir) => {
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
if (playlistsStore) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
return remap;
},
);
@@ -1325,6 +1357,10 @@ async function main() {
// Если в sourceCache нет app-tokens, remap пустой.
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
if (playlistsStore) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
return remap;
},
);
@@ -1343,6 +1379,11 @@ async function main() {
...(p.detail ? { detail: p.detail } : null),
});
});
const cacheDir = projectStore.getOpenCacheDir();
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
}
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
@@ -1370,7 +1411,7 @@ async function main() {
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
try {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
const { project, pendingAppPlaylists } = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({
kind: 'import',
stage:
@@ -1379,6 +1420,16 @@ async function main() {
...(p.detail ? { detail: p.detail } : null),
});
});
if (playlistsStore && pendingAppPlaylists.length > 0) {
for (const pl of pendingAppPlaylists) {
const created = await playlistsStore.createPlaylist(pl.name);
const paths = pl.tracks.map((t) => t.absPath);
if (paths.length > 0) {
await playlistsStore.addAudioFiles(created.id, paths);
}
}
emitPlaylistsState();
}
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
@@ -1389,7 +1440,7 @@ async function main() {
});
registerHandler(
ipcChannels.project.exportZip,
async ({ projectId, storylineSelections, npcIds, labels }) => {
async ({ projectId, storylineSelections, npcIds, labels, playlistIds }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
@@ -1427,12 +1478,19 @@ async function main() {
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
async (exportRoot) => {
if (!playlistsStore || !playlistIds?.length) return;
await playlistsStore.packForExport(
playlistIds.map((id) => asPlaylistId(id)),
exportRoot,
);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
emitZipProgress({ kind: 'export', stage: 'done', percent: 0, detail });
throw err;
}
},
@@ -1674,6 +1732,93 @@ async function main() {
}
return { url: playersStore.getImageUrl(id) };
});
registerHandler(ipcChannels.playlists.getState, async () => {
if (!playlistsStore) return { playlists: [], audios: [] };
await playlistsStore.ensureLoaded();
return playlistsStore.getState();
});
registerHandler(ipcChannels.playlists.create, async ({ name }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.createPlaylist(name);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.rename, async ({ id, name }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.renamePlaylist(id, name);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.duplicate, async ({ id }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.duplicatePlaylist(id);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.delete, async ({ id }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
await playlistsStore.deletePlaylist(id);
const state = playlistsStore.getState();
emitPlaylistsState();
return { state };
});
registerHandler(ipcChannels.playlists.setOrder, async ({ playlistIds }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
await playlistsStore.setPlaylistsOrder(playlistIds);
const state = playlistsStore.getState();
emitPlaylistsState();
return { state };
});
registerHandler(ipcChannels.playlists.setTracksOrder, async ({ playlistId, trackIds }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.setTracksOrder(playlistId, trackIds);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.addAudioFiles, async ({ playlistId, filePaths }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.addAudioFiles(playlistId, filePaths);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.pickAudioFiles, async () => {
const win = BrowserWindow.getFocusedWindow();
const { canceled, filePaths } = await dialog.showOpenDialog(win ?? undefined, {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: openDialogFilterLabel('audio', app.getLocale()),
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled) return { filePaths: [] };
return { filePaths };
});
registerHandler(ipcChannels.playlists.removeTrack, async ({ playlistId, trackId }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const playlist = await playlistsStore.removeTrack(playlistId, trackId);
const state = playlistsStore.getState();
emitPlaylistsState();
return { playlist, state };
});
registerHandler(ipcChannels.playlists.renameAudio, async ({ audioId, title }) => {
if (!playlistsStore) throw new Error('Playlists store unavailable');
const audio = await playlistsStore.renameAudio(audioId, title);
const state = playlistsStore.getState();
emitPlaylistsState();
return { audio, state };
});
registerHandler(ipcChannels.playlists.audioUrl, ({ id }) => {
return { url: playlistsStore?.getAudioUrl(id) ?? null };
});
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
return { state: sceneNpcTokensSessionStore.getState() };
});