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
+24 -4
View File
@@ -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
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() };
});
+70
View File
@@ -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);
});
+424
View File
@@ -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 */
}
}
}
}
+11 -3
View File
@@ -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,
+6 -2
View File
@@ -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 });
+47
View File
@@ -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();