feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -279,10 +279,17 @@ function collectSceneAudioPaths(
|
||||
return paths;
|
||||
}
|
||||
|
||||
export type FoundryPendingAppPlaylist = {
|
||||
name: string;
|
||||
tracks: { title: string; absPath: string; mime: string }[];
|
||||
};
|
||||
|
||||
export type FoundryBuiltProject = {
|
||||
project: Project;
|
||||
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
||||
previewSources: { sceneId: SceneId; absPath: string }[];
|
||||
/** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */
|
||||
pendingAppPlaylists: FoundryPendingAppPlaylist[];
|
||||
};
|
||||
|
||||
export async function buildProjectFromFoundryDocuments(
|
||||
@@ -406,15 +413,28 @@ export async function buildProjectFromFoundryDocuments(
|
||||
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
||||
);
|
||||
const campaignAudios: Project['campaignAudios'] = [];
|
||||
const pendingAppPlaylists: FoundryPendingAppPlaylist[] = [];
|
||||
for (const pl of docs.playlists) {
|
||||
if (usedPlaylistIds.has(pl._id)) continue;
|
||||
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||
const tracks: FoundryPendingAppPlaylist['tracks'] = [];
|
||||
for (const s of sounds) {
|
||||
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
||||
const assetId = await resolveAndImport(ctx, manifest, s.path);
|
||||
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
|
||||
campaignAudios.push({ assetId, autoplay: false, loop: true });
|
||||
const abs = await resolveFoundryAssetPath(manifest, s.path);
|
||||
if (!abs) continue;
|
||||
const kind = classifyMediaPath(abs);
|
||||
if (!kind || kind.type !== 'audio') continue;
|
||||
const title =
|
||||
(typeof s.name === 'string' && s.name.trim()) ||
|
||||
path.basename(s.path).replace(/\.[^.]+$/u, '') ||
|
||||
'Track';
|
||||
tracks.push({ title, absPath: abs, mime: kind.mime });
|
||||
}
|
||||
if (tracks.length === 0) continue;
|
||||
pendingAppPlaylists.push({
|
||||
name: (typeof pl.name === 'string' && pl.name.trim()) || 'Playlist',
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
|
||||
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
|
||||
@@ -538,7 +558,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
sceneGraphEdges,
|
||||
};
|
||||
|
||||
return { project, previewSources };
|
||||
return { project, previewSources, pendingAppPlaylists };
|
||||
}
|
||||
|
||||
/** Actor folders Foundry → дерево групп НПС. */
|
||||
|
||||
+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() };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { filterPlaylistsByQuery } from '../../shared/types/appPlaylists';
|
||||
import { asPlaylistAudioId, asPlaylistId, asPlaylistTrackId } from '../../shared/types/ids';
|
||||
|
||||
import { PlaylistsStore } from './playlistsStore';
|
||||
|
||||
void test('filterPlaylistsByQuery: matches playlist name and track title', () => {
|
||||
const state = {
|
||||
playlists: [
|
||||
{
|
||||
id: asPlaylistId('p1'),
|
||||
name: 'Battle',
|
||||
tracks: [{ id: asPlaylistTrackId('t1'), audioId: asPlaylistAudioId('a1') }],
|
||||
},
|
||||
{
|
||||
id: asPlaylistId('p2'),
|
||||
name: 'Town',
|
||||
tracks: [{ id: asPlaylistTrackId('t2'), audioId: asPlaylistAudioId('a2') }],
|
||||
},
|
||||
],
|
||||
audios: [
|
||||
{
|
||||
id: asPlaylistAudioId('a1'),
|
||||
title: 'Drums',
|
||||
originalName: 'drums.mp3',
|
||||
relPath: 'files/a.mp3',
|
||||
sha256: '1',
|
||||
mime: 'audio/mpeg',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
{
|
||||
id: asPlaylistAudioId('a2'),
|
||||
title: 'Market',
|
||||
originalName: 'market.mp3',
|
||||
relPath: 'files/b.mp3',
|
||||
sha256: '2',
|
||||
mime: 'audio/mpeg',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
const byName = filterPlaylistsByQuery(state, 'bat');
|
||||
assert.equal(byName.playlists.length, 1);
|
||||
assert.equal(byName.playlists[0]?.id, asPlaylistId('p1'));
|
||||
|
||||
const byTrack = filterPlaylistsByQuery(state, 'market');
|
||||
assert.equal(byTrack.playlists.length, 1);
|
||||
assert.ok(byTrack.matchedAudioIds.has(asPlaylistAudioId('a2')));
|
||||
});
|
||||
|
||||
void test('PlaylistsStore: create playlist and add audio by hash reuse', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-pl-'));
|
||||
const store = new PlaylistsStore(dir);
|
||||
await store.ensureLoaded();
|
||||
const pl = await store.createPlaylist('Test');
|
||||
const audioPath = path.join(dir, 'sample.mp3');
|
||||
fs.writeFileSync(audioPath, Buffer.from('ID3fake-audio-bytes'));
|
||||
|
||||
await store.addAudioFiles(pl.id, [audioPath]);
|
||||
await store.addAudioFiles(pl.id, [audioPath]);
|
||||
const state = store.getState();
|
||||
assert.equal(state.audios.length, 1);
|
||||
assert.equal(state.playlists[0]?.tracks.length, 2);
|
||||
assert.equal(state.playlists[0]?.tracks[0]?.audioId, state.playlists[0]?.tracks[1]?.audioId);
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
AppPlaylist,
|
||||
PlaylistAudio,
|
||||
PlaylistTrack,
|
||||
PlaylistsExportBundle,
|
||||
PlaylistsState,
|
||||
} from '../../shared/types/appPlaylists';
|
||||
import {
|
||||
normalizePlaylistName,
|
||||
titleFromAudioFileName,
|
||||
} from '../../shared/types/appPlaylists';
|
||||
import {
|
||||
asPlaylistAudioId,
|
||||
asPlaylistId,
|
||||
asPlaylistTrackId,
|
||||
type PlaylistAudioId,
|
||||
type PlaylistId,
|
||||
type PlaylistTrackId,
|
||||
} from '../../shared/types/ids';
|
||||
|
||||
type PlaylistsManifest = {
|
||||
playlists: AppPlaylist[];
|
||||
audios: PlaylistAudio[];
|
||||
};
|
||||
|
||||
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
const e = ext.toLowerCase();
|
||||
if (e === '.mp3') return 'audio/mpeg';
|
||||
if (e === '.wav') return 'audio/wav';
|
||||
if (e === '.ogg') return 'audio/ogg';
|
||||
if (e === '.m4a') return 'audio/mp4';
|
||||
if (e === '.aac') return 'audio/aac';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function safeFileBase(name: string): string {
|
||||
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||
return base || 'audio';
|
||||
}
|
||||
|
||||
function randomPlaylistId(): PlaylistId {
|
||||
return asPlaylistId(`pl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
function randomAudioId(): PlaylistAudioId {
|
||||
return asPlaylistAudioId(`pla_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
function randomTrackId(): PlaylistTrackId {
|
||||
return asPlaylistTrackId(`plt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
function uniquePlaylistName(desired: string, playlists: AppPlaylist[], exceptId?: PlaylistId): string {
|
||||
const base = normalizePlaylistName(desired);
|
||||
const taken = new Set(
|
||||
playlists.filter((p) => p.id !== exceptId).map((p) => p.name.trim().toLowerCase()),
|
||||
);
|
||||
if (!taken.has(base.toLowerCase())) return base;
|
||||
let i = 2;
|
||||
while (taken.has(`${base} (${String(i)})`.toLowerCase())) i += 1;
|
||||
return `${base} (${String(i)})`;
|
||||
}
|
||||
|
||||
export class PlaylistsStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly filesDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private playlists: AppPlaylist[] = [];
|
||||
private audios: PlaylistAudio[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'playlists');
|
||||
this.filesDir = path.join(this.rootDir, 'files');
|
||||
this.manifestPath = path.join(this.rootDir, 'playlists.json');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.filesDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as PlaylistsManifest;
|
||||
this.audios = Array.isArray(parsed.audios) ? parsed.audios : [];
|
||||
const audioIds = new Set(this.audios.map((a) => a.id));
|
||||
this.playlists = (Array.isArray(parsed.playlists) ? parsed.playlists : []).map((p) => ({
|
||||
id: asPlaylistId(String(p.id)),
|
||||
name: normalizePlaylistName(String(p.name ?? '')),
|
||||
tracks: (Array.isArray(p.tracks) ? p.tracks : [])
|
||||
.filter((t) => audioIds.has(t.audioId))
|
||||
.map((t) => ({
|
||||
id: asPlaylistTrackId(String(t.id)),
|
||||
audioId: asPlaylistAudioId(String(t.audioId)),
|
||||
})),
|
||||
}));
|
||||
} catch {
|
||||
this.playlists = [];
|
||||
this.audios = [];
|
||||
await this.persist();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
getState(): PlaylistsState {
|
||||
return {
|
||||
playlists: this.playlists.map((p) => ({ ...p, tracks: [...p.tracks] })),
|
||||
audios: [...this.audios],
|
||||
};
|
||||
}
|
||||
|
||||
getAudioReadInfo(id: PlaylistAudioId): { absPath: string; mime: string } | null {
|
||||
const audio = this.audios.find((a) => a.id === id);
|
||||
if (!audio) return null;
|
||||
return {
|
||||
absPath: path.join(this.rootDir, audio.relPath),
|
||||
mime: audio.mime || mimeFromExt(path.extname(audio.relPath)),
|
||||
};
|
||||
}
|
||||
|
||||
getAudioUrl(id: PlaylistAudioId): string | null {
|
||||
if (!this.getAudioReadInfo(id)) return null;
|
||||
return `dnd://playlist-audio?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const manifest: PlaylistsManifest = { playlists: this.playlists, audios: this.audios };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async createPlaylist(name?: string): Promise<AppPlaylist> {
|
||||
await this.ensureLoaded();
|
||||
const pl: AppPlaylist = {
|
||||
id: randomPlaylistId(),
|
||||
name: uniquePlaylistName(name ?? 'Playlist', this.playlists),
|
||||
tracks: [],
|
||||
};
|
||||
this.playlists = [pl, ...this.playlists];
|
||||
await this.persist();
|
||||
return pl;
|
||||
}
|
||||
|
||||
async renamePlaylist(id: PlaylistId, name: string): Promise<AppPlaylist> {
|
||||
await this.ensureLoaded();
|
||||
const idx = this.playlists.findIndex((p) => p.id === id);
|
||||
if (idx < 0) throw new Error('Playlist not found');
|
||||
const next = {
|
||||
...this.playlists[idx]!,
|
||||
name: uniquePlaylistName(name, this.playlists, id),
|
||||
};
|
||||
this.playlists = this.playlists.map((p, i) => (i === idx ? next : p));
|
||||
await this.persist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async duplicatePlaylist(id: PlaylistId): Promise<AppPlaylist> {
|
||||
await this.ensureLoaded();
|
||||
const src = this.playlists.find((p) => p.id === id);
|
||||
if (!src) throw new Error('Playlist not found');
|
||||
const copy: AppPlaylist = {
|
||||
id: randomPlaylistId(),
|
||||
name: uniquePlaylistName(`${src.name} copy`, this.playlists),
|
||||
tracks: src.tracks.map((t) => ({ id: randomTrackId(), audioId: t.audioId })),
|
||||
};
|
||||
const at = this.playlists.findIndex((p) => p.id === id);
|
||||
const next = [...this.playlists];
|
||||
next.splice(at + 1, 0, copy);
|
||||
this.playlists = next;
|
||||
await this.persist();
|
||||
return copy;
|
||||
}
|
||||
|
||||
async deletePlaylist(id: PlaylistId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
this.playlists = this.playlists.filter((p) => p.id !== id);
|
||||
await this.pruneOrphanAudios();
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const map = new Map(this.playlists.map((p) => [p.id, p]));
|
||||
const next: AppPlaylist[] = [];
|
||||
for (const id of playlistIds) {
|
||||
const p = map.get(id);
|
||||
if (p) {
|
||||
next.push(p);
|
||||
map.delete(id);
|
||||
}
|
||||
}
|
||||
for (const p of map.values()) next.push(p);
|
||||
this.playlists = next;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async setTracksOrder(playlistId: PlaylistId, trackIds: PlaylistTrackId[]): Promise<AppPlaylist> {
|
||||
await this.ensureLoaded();
|
||||
const pl = this.playlists.find((p) => p.id === playlistId);
|
||||
if (!pl) throw new Error('Playlist not found');
|
||||
const map = new Map(pl.tracks.map((t) => [t.id, t]));
|
||||
const tracks: PlaylistTrack[] = [];
|
||||
for (const id of trackIds) {
|
||||
const t = map.get(id);
|
||||
if (t) {
|
||||
tracks.push(t);
|
||||
map.delete(id);
|
||||
}
|
||||
}
|
||||
for (const t of map.values()) tracks.push(t);
|
||||
const next = { ...pl, tracks };
|
||||
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
|
||||
await this.persist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async renameAudio(audioId: PlaylistAudioId, title: string): Promise<PlaylistAudio> {
|
||||
await this.ensureLoaded();
|
||||
const idx = this.audios.findIndex((a) => a.id === audioId);
|
||||
if (idx < 0) throw new Error('Audio not found');
|
||||
const next = { ...this.audios[idx]!, title: title.trim() || this.audios[idx]!.title };
|
||||
this.audios = this.audios.map((a, i) => (i === idx ? next : a));
|
||||
await this.persist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async removeTrack(playlistId: PlaylistId, trackId: PlaylistTrackId): Promise<AppPlaylist> {
|
||||
await this.ensureLoaded();
|
||||
const pl = this.playlists.find((p) => p.id === playlistId);
|
||||
if (!pl) throw new Error('Playlist not found');
|
||||
const next = { ...pl, tracks: pl.tracks.filter((t) => t.id !== trackId) };
|
||||
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
|
||||
await this.pruneOrphanAudios();
|
||||
await this.persist();
|
||||
return next;
|
||||
}
|
||||
|
||||
async addAudioFiles(playlistId: PlaylistId, filePaths: string[]): Promise<AppPlaylist> {
|
||||
await this.ensureLoaded();
|
||||
const pl = this.playlists.find((p) => p.id === playlistId);
|
||||
if (!pl) throw new Error('Playlist not found');
|
||||
const tracks = [...pl.tracks];
|
||||
for (const filePath of filePaths) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!AUDIO_EXTS.has(ext)) continue;
|
||||
const audio = await this.importOrReuseFile(filePath);
|
||||
tracks.push({ id: randomTrackId(), audioId: audio.id });
|
||||
}
|
||||
const next = { ...pl, tracks };
|
||||
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
|
||||
await this.persist();
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Импорт файла с известным содержимым (Foundry / zip bundle). */
|
||||
async importAudioBuffer(input: {
|
||||
preferredId?: PlaylistAudioId;
|
||||
title: string;
|
||||
originalName: string;
|
||||
mime: string;
|
||||
buffer: Buffer;
|
||||
}): Promise<PlaylistAudio> {
|
||||
await this.ensureLoaded();
|
||||
const sha256 = crypto.createHash('sha256').update(input.buffer).digest('hex');
|
||||
const existing = this.audios.find((a) => a.sha256 === sha256);
|
||||
if (existing) return existing;
|
||||
|
||||
const id =
|
||||
input.preferredId && !this.audios.some((a) => a.id === input.preferredId)
|
||||
? input.preferredId
|
||||
: randomAudioId();
|
||||
const ext = path.extname(input.originalName).toLowerCase() || '.mp3';
|
||||
const fileName = `${id}_${safeFileBase(input.title)}${AUDIO_EXTS.has(ext) ? ext : '.mp3'}`;
|
||||
const abs = path.join(this.filesDir, fileName);
|
||||
await fs.writeFile(abs, input.buffer);
|
||||
const audio: PlaylistAudio = {
|
||||
id,
|
||||
title: input.title.trim() || titleFromAudioFileName(input.originalName),
|
||||
originalName: input.originalName,
|
||||
relPath: path.join('files', fileName).replace(/\\/gu, '/'),
|
||||
sha256,
|
||||
mime: input.mime || mimeFromExt(ext),
|
||||
sizeBytes: input.buffer.length,
|
||||
};
|
||||
this.audios = [...this.audios, audio];
|
||||
await this.persist();
|
||||
return audio;
|
||||
}
|
||||
|
||||
async importBundle(
|
||||
bundle: PlaylistsExportBundle,
|
||||
readFile: (relPath: string) => Promise<Buffer>,
|
||||
): Promise<{ importedPlaylistIds: PlaylistId[] }> {
|
||||
await this.ensureLoaded();
|
||||
const audioIdMap = new Map<PlaylistAudioId, PlaylistAudioId>();
|
||||
for (const a of bundle.audios ?? []) {
|
||||
try {
|
||||
const buf = await readFile(a.relPath);
|
||||
const imported = await this.importAudioBuffer({
|
||||
title: a.title,
|
||||
originalName: a.originalName,
|
||||
mime: a.mime,
|
||||
buffer: buf,
|
||||
});
|
||||
audioIdMap.set(a.id, imported.id);
|
||||
} catch {
|
||||
/* skip missing file */
|
||||
}
|
||||
}
|
||||
const importedPlaylistIds: PlaylistId[] = [];
|
||||
for (const pl of bundle.playlists ?? []) {
|
||||
const tracks: PlaylistTrack[] = [];
|
||||
for (const t of pl.tracks ?? []) {
|
||||
const mapped = audioIdMap.get(t.audioId);
|
||||
if (!mapped) continue;
|
||||
tracks.push({ id: randomTrackId(), audioId: mapped });
|
||||
}
|
||||
const created: AppPlaylist = {
|
||||
id: randomPlaylistId(),
|
||||
name: uniquePlaylistName(pl.name, this.playlists),
|
||||
tracks,
|
||||
};
|
||||
this.playlists = [...this.playlists, created];
|
||||
importedPlaylistIds.push(created.id);
|
||||
}
|
||||
await this.persist();
|
||||
return { importedPlaylistIds };
|
||||
}
|
||||
|
||||
buildExportBundle(playlistIds: PlaylistId[]): {
|
||||
bundle: PlaylistsExportBundle;
|
||||
fileAbsPaths: { relPath: string; absPath: string }[];
|
||||
} {
|
||||
const selected = this.playlists.filter((p) => playlistIds.includes(p.id));
|
||||
const usedAudioIds = new Set<PlaylistAudioId>();
|
||||
for (const p of selected) {
|
||||
for (const t of p.tracks) usedAudioIds.add(t.audioId);
|
||||
}
|
||||
const audios = this.audios.filter((a) => usedAudioIds.has(a.id));
|
||||
const fileAbsPaths = audios.map((a) => ({
|
||||
relPath: a.relPath,
|
||||
absPath: path.join(this.rootDir, a.relPath),
|
||||
}));
|
||||
return {
|
||||
bundle: { version: 1, playlists: selected, audios },
|
||||
fileAbsPaths,
|
||||
};
|
||||
}
|
||||
|
||||
async packForExport(playlistIds: PlaylistId[], exportRoot: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
if (playlistIds.length === 0) return;
|
||||
const { bundle, fileAbsPaths } = this.buildExportBundle(playlistIds);
|
||||
if (bundle.playlists.length === 0) return;
|
||||
const outDir = path.join(exportRoot, 'app-playlists');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
for (const f of fileAbsPaths) {
|
||||
const dest = path.join(outDir, f.relPath);
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.copyFile(f.absPath, dest);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(outDir, 'playlists.json'),
|
||||
`${JSON.stringify(bundle, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
async importFromExportDir(sourceCache: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const manifestPath = path.join(sourceCache, 'app-playlists', 'playlists.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(manifestPath, 'utf8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
let bundle: PlaylistsExportBundle;
|
||||
try {
|
||||
bundle = JSON.parse(raw) as PlaylistsExportBundle;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await this.importBundle(bundle, async (relPath) => {
|
||||
return fs.readFile(path.join(sourceCache, 'app-playlists', relPath));
|
||||
});
|
||||
}
|
||||
|
||||
private async importOrReuseFile(filePath: string): Promise<PlaylistAudio> {
|
||||
const buf = await fs.readFile(filePath);
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const existing = this.audios.find((a) => a.sha256 === sha256);
|
||||
if (existing) return existing;
|
||||
const originalName = path.basename(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return this.importAudioBuffer({
|
||||
title: titleFromAudioFileName(originalName),
|
||||
originalName,
|
||||
mime: mimeFromExt(ext),
|
||||
buffer: buf,
|
||||
});
|
||||
}
|
||||
|
||||
private async pruneOrphanAudios(): Promise<void> {
|
||||
const used = new Set<PlaylistAudioId>();
|
||||
for (const p of this.playlists) {
|
||||
for (const t of p.tracks) used.add(t.audioId);
|
||||
}
|
||||
const orphans = this.audios.filter((a) => !used.has(a.id));
|
||||
this.audios = this.audios.filter((a) => used.has(a.id));
|
||||
for (const a of orphans) {
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, a.relPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,6 +411,10 @@ export class ZipProjectStore {
|
||||
return this.openProject?.project ?? null;
|
||||
}
|
||||
|
||||
getOpenCacheDir(): string | null {
|
||||
return this.openProject?.cacheDir ?? null;
|
||||
}
|
||||
|
||||
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
||||
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
||||
const open = this.openProject;
|
||||
@@ -2039,7 +2043,7 @@ export class ZipProjectStore {
|
||||
async importProjectFromFoundry(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<Project> {
|
||||
): Promise<{ project: Project; pendingAppPlaylists: import('../foundry/foundryImport').FoundryPendingAppPlaylist[] }> {
|
||||
await this.ensureRoots();
|
||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||
try {
|
||||
@@ -2049,7 +2053,7 @@ export class ZipProjectStore {
|
||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
||||
|
||||
const { project } = await buildProjectFromFoundryDocuments(
|
||||
const { project, pendingAppPlaylists } = await buildProjectFromFoundryDocuments(
|
||||
loaded.manifest,
|
||||
loaded.docs,
|
||||
cacheDir,
|
||||
@@ -2070,7 +2074,7 @@ export class ZipProjectStore {
|
||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||
await this.enqueuePack(cacheDir, zipPath);
|
||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return this.openProject.project;
|
||||
return { project: this.openProject.project, pendingAppPlaylists };
|
||||
} finally {
|
||||
await loaded.cleanup();
|
||||
}
|
||||
@@ -2184,6 +2188,7 @@ export class ZipProjectStore {
|
||||
labels: StorylineLabels,
|
||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
||||
packAppPlaylists?: (exportRoot: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
||||
await this.ensureRoots();
|
||||
@@ -2217,6 +2222,9 @@ export class ZipProjectStore {
|
||||
const tokenIds = collectTokenIdsFromProject(partial);
|
||||
await packAppTokens(tokenIds, exportCache);
|
||||
}
|
||||
if (packAppPlaylists) {
|
||||
await packAppPlaylists(exportCache);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const toWrite: Project = {
|
||||
...partial,
|
||||
|
||||
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
|
||||
import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
|
||||
import type { PlayersStore } from '../players/playersStore';
|
||||
import type { PlaylistsStore } from '../playlists/playlistsStore';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
import type { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
@@ -76,12 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`.
|
||||
*/
|
||||
export function registerDndAssetProtocol(
|
||||
projectStore: ZipProjectStore,
|
||||
tokensStore: TokensStore,
|
||||
playersStore: PlayersStore | null,
|
||||
playlistsStore: PlaylistsStore | null = null,
|
||||
): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -96,6 +98,8 @@ export function registerDndAssetProtocol(
|
||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||
} else if (url.hostname === 'player') {
|
||||
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
||||
} else if (url.hostname === 'playlist-audio') {
|
||||
info = playlistsStore?.getAudioReadInfo(asPlaylistAudioId(id)) ?? null;
|
||||
}
|
||||
if (!info) {
|
||||
return new Response(null, { status: 404 });
|
||||
|
||||
@@ -20,6 +20,7 @@ export type WindowKind =
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'playlists'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
@@ -54,6 +55,10 @@ let pendingSceneDescriptionHtml = '';
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Окно плейлистов на пульте. */
|
||||
const PLAYLISTS_WINDOW_WIDTH = 720;
|
||||
const PLAYLISTS_WINDOW_HEIGHT = 800;
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||
@@ -163,6 +168,7 @@ function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closePlaylistsWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
@@ -211,6 +217,8 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'sceneDescription.html';
|
||||
case 'materials':
|
||||
return 'materials.html';
|
||||
case 'playlists':
|
||||
return 'playlists.html';
|
||||
case 'npcsEditor':
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
@@ -286,6 +294,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'control') return { width: 1200, height: 800 };
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||
@@ -396,6 +405,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'playlists' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'tokenPathEditor' ||
|
||||
@@ -586,6 +596,13 @@ export function closeMaterialsWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function closePlaylistsWindow(): void {
|
||||
const win = windows.get('playlists');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -745,6 +762,36 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function openPlaylistsWindow(): void {
|
||||
const existing = windows.get('playlists');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('playlists', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const { width, height } = win.getBounds();
|
||||
|
||||
Reference in New Issue
Block a user