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