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();
+16
View File
@@ -1826,6 +1826,22 @@ export function ControlApp() {
</svg>
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.playlistsTool')}
ariaLabel={t('control.playlistsTool')}
data-testid="control-playlists-tool"
onClick={() => {
void api.invoke(ipcChannels.windows.openPlaylists, {}).catch((err) => {
console.error('[control] openPlaylists failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
🎵
</span>
</Button>
<Button
variant="ghost"
iconOnly
+29 -3
View File
@@ -60,6 +60,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal';
import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -161,6 +162,7 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
@@ -382,7 +384,10 @@ export function EditorApp() {
);
useEffect(() => {
if (presentationOpen) setLaunching(false);
if (presentationOpen) {
setLaunching(false);
setPlaylistsManagerOpen(false);
}
}, [presentationOpen]);
const currentProjectName = state.project?.meta.name ?? '';
@@ -864,6 +869,23 @@ export function EditorApp() {
{t('top.players')}
</button>
) : null}
<button
type="button"
data-testid="playlists-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlaylistsManagerOpen(true);
}}
>
{t('top.playlists')}
</button>
</div>
<div className={styles.flex1} />
{appVersionText ? (
@@ -1561,8 +1583,8 @@ export function EditorApp() {
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections, npcIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
onExport={async (projectId, selections, npcIds, playlistIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
}}
/>
<ImportSourceModal
@@ -1656,6 +1678,10 @@ export function EditorApp() {
open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)}
/>
<PlaylistsManagerModal
open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)}
/>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
@@ -0,0 +1,14 @@
.dialog {
width: min(960px, calc(100vw - 48px));
height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 4px 8px;
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import {
PlaylistsPanel,
playlistsPanelLabelsFromT,
} from '../shared/playlists/PlaylistsPanel';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import panelStyles from './PlaylistsModal.module.css';
export function PlaylistsManagerModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
if (!open) return null;
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, panelStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="playlists-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('playlists.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={panelStyles.body}>
<PlaylistsPanel labels={labels} />
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+60 -23
View File
@@ -18,6 +18,7 @@ import {
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button, Select } from '../shared/ui/controls';
import { useAppPlaylists } from '../shared/playlists/useAppPlaylists';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -37,6 +38,7 @@ type ExportProjectModalProps = {
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
playlistIds: string[],
) => Promise<void>;
};
@@ -50,12 +52,14 @@ export function ExportProjectModal({
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const playlistsState = useAppPlaylists();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [selectedPlaylistIds, setSelectedPlaylistIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -67,6 +71,7 @@ export function ExportProjectModal({
setError(null);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setSelectedPlaylistIds(new Set());
setStep('storylines');
setNpcs([]);
}, [initialProjectId, open]);
@@ -156,7 +161,7 @@ export function ExportProjectModal({
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections, npcIds);
await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -168,7 +173,7 @@ export function ExportProjectModal({
const goNextFromStorylines = () => {
if (!canContinueStorylines) return;
if (npcs.length === 0) {
if (npcs.length === 0 && playlistsState.playlists.length === 0) {
runExport([]);
return;
}
@@ -248,26 +253,58 @@ export function ExportProjectModal({
</div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
{npcs.length > 0 ? (
<>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
</>
) : null}
{playlistsState.playlists.length > 0 ? (
<>
<div className={styles.fieldLabel}>{t('export.playlists')}</div>
<div className={styles.muted}>{t('export.playlistsHint')}</div>
<div className={styles.storylineChecklist}>
{playlistsState.playlists.map((pl) => (
<label key={pl.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedPlaylistIds.has(pl.id)}
disabled={saving}
onChange={() => {
setSelectedPlaylistIds((prev) => {
const next = new Set(prev);
if (next.has(pl.id)) next.delete(pl.id);
else next.add(pl.id);
return next;
});
}}
/>
<span>
{pl.name} ({pl.tracks.length})
</span>
</label>
))}
</div>
</>
) : null}
</div>
)}
@@ -289,7 +326,7 @@ export function ExportProjectModal({
disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines}
>
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
{npcs.length > 0 || playlistsState.playlists.length > 0 ? t('export.next') : t('export.saveAs')}
</Button>
) : (
<Button
@@ -111,6 +111,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.project': 'Проект',
'top.file': 'Файл',
'top.players': 'Игроки',
'top.playlists': 'Плейлисты',
'top.backToProjects': 'К списку проектов',
'top.appVersion': 'Версия приложения',
'top.run': 'Запустить',
@@ -325,6 +326,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.npcsHint':
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
'export.selectAllNpcs': 'Отметить всех',
'export.playlists': 'Плейлисты',
'export.playlistsHint': 'По умолчанию не выбраны. Отметьте плейлисты, которые нужно упаковать в архив.',
'export.next': 'Далее',
'export.back': 'Назад',
'export.exporting': 'Экспорт…',
@@ -459,6 +462,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'players.deleteTeamTitle': 'Удаление команды',
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
'playlists.title': 'Плейлисты',
'playlists.search': 'Поиск по плейлистам и трекам…',
'playlists.create': 'Создать',
'playlists.rename': 'Переименовать',
'playlists.duplicate': 'Дублировать',
'playlists.delete': 'Удалить',
'playlists.deleteTitle': 'Удаление плейлиста',
'playlists.deleteConfirm':
'Удалить плейлист «{name}»? Если он сейчас играет — воспроизведение остановится.',
'playlists.addAudio': 'Добавить аудио',
'playlists.empty': 'Нет плейлистов. Создайте первый.',
'playlists.emptyTracks': 'В плейлисте пока нет треков.',
'playlists.dropAudioHint': 'Перетащите аудиофайлы сюда',
'playlists.untitled': 'Без названия',
'playlists.play': 'Играть',
'playlists.pause': 'Пауза',
'playlists.prev': 'Предыдущий',
'playlists.next': 'Следующий',
'playlists.volume': 'Громкость',
'playlists.scrub': 'Перемотка',
'playlists.emptyPlayer': 'Ничего не играет',
'playlists.playPlaylist': 'Играть плейлист',
'playlists.removeTrack': 'Убрать',
'playlists.renameAudio': 'Переименовать трек',
'npcs.open': 'НПС',
'npcs.editorTitle': 'НПС',
'npcs.add': 'Добавить',
@@ -619,6 +647,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.markerTool': 'Маркер',
'control.markerColor': 'Цвет маркера',
'control.materialsTool': 'Материалы',
'control.playlistsTool': 'Плейлисты',
'control.npcsTool': 'НПС',
'control.descriptionMissing': 'Описание отсутствует',
'control.effects': 'ЭФФЕКТЫ',
@@ -761,6 +790,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'top.project': 'Project',
'top.file': 'File',
'top.players': 'Players',
'top.playlists': 'Playlists',
'top.backToProjects': 'Back to projects',
'top.appVersion': 'App version',
'top.run': 'Run',
@@ -975,6 +1005,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.npcsHint':
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
'export.selectAllNpcs': 'Select all',
'export.playlists': 'Playlists',
'export.playlistsHint': 'None selected by default. Check playlists to include in the archive.',
'export.next': 'Next',
'export.back': 'Back',
'export.exporting': 'Exporting…',
@@ -1111,6 +1143,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'players.deleteTeamConfirm':
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
'playlists.title': 'Playlists',
'playlists.search': 'Search playlists and tracks…',
'playlists.create': 'Create',
'playlists.rename': 'Rename',
'playlists.duplicate': 'Duplicate',
'playlists.delete': 'Delete',
'playlists.deleteTitle': 'Delete playlist',
'playlists.deleteConfirm':
'Delete playlist “{name}”? If it is playing, playback will stop.',
'playlists.addAudio': 'Add audio',
'playlists.empty': 'No playlists yet. Create one.',
'playlists.emptyTracks': 'This playlist has no tracks yet.',
'playlists.dropAudioHint': 'Drop audio files here',
'playlists.untitled': 'Untitled',
'playlists.play': 'Play',
'playlists.pause': 'Pause',
'playlists.prev': 'Previous',
'playlists.next': 'Next',
'playlists.volume': 'Volume',
'playlists.scrub': 'Seek',
'playlists.emptyPlayer': 'Nothing playing',
'playlists.playPlaylist': 'Play playlist',
'playlists.removeTrack': 'Remove',
'playlists.renameAudio': 'Rename track',
'npcs.open': 'NPCs',
'npcs.editorTitle': 'NPCs',
'npcs.add': 'Add',
@@ -1271,6 +1328,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.markerTool': 'Marker',
'control.markerColor': 'Marker color',
'control.materialsTool': 'Materials',
'control.playlistsTool': 'Playlists',
'control.npcsTool': 'NPCs',
'control.descriptionMissing': 'No description',
'control.effects': 'EFFECTS',
@@ -157,6 +157,7 @@ type Actions = {
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
playlistIds?: string[],
) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
};
@@ -901,6 +902,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
playlistIds: string[] = [],
) => {
try {
const res = await api.invoke(ipcChannels.project.exportZip, {
@@ -908,6 +910,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
storylineSelections,
npcIds,
labels,
playlistIds,
});
if (res.canceled) return;
} finally {
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/playlists/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,15 @@
.page {
height: 100vh;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
}
.panel {
flex: 1;
min-height: 0;
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useMemo } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import {
PlaylistsPanel,
playlistsPanelLabelsFromT,
} from '../shared/playlists/PlaylistsPanel';
import styles from './PlaylistsApp.module.css';
export function PlaylistsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closePlaylists, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
return (
<div className={styles.page} data-testid="playlists-app">
<PlaylistsPanel labels={labels} className={styles.panel} readOnly />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { PlaylistsApp } from './PlaylistsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Playlists">
<EditorI18nProvider>
<PlaylistsApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
@@ -0,0 +1,67 @@
.bar {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--stroke);
border-radius: var(--radius-lg);
background: var(--color-overlay-dark-2);
}
.meta {
min-width: 0;
}
.title {
font-weight: 800;
font-size: var(--text-sm);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
margin-top: 2px;
font-size: var(--text-xs);
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transport {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.scrubRow {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center;
}
.scrub {
width: 100%;
}
.time {
font-size: var(--text-xs);
color: var(--text2);
font-variant-numeric: tabular-nums;
min-width: 2.5em;
}
.volume {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
}
.volumeLabel {
font-size: var(--text-xs);
color: var(--text2);
}
@@ -0,0 +1,153 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../ui/controls';
import styles from './PlaylistPlayerBar.module.css';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function clampGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
export type PlaylistPlayerBarProps = {
title: string;
subtitle?: string;
audioEl: HTMLAudioElement | null;
playing: boolean;
gain: number;
onGainChange: (gain: number) => void;
onTogglePlay: () => void;
onPrev: () => void;
onNext: () => void;
playLabel: string;
pauseLabel: string;
prevLabel: string;
nextLabel: string;
volumeLabel: string;
scrubLabel: string;
emptyLabel: string;
};
export function PlaylistPlayerBar({
title,
subtitle,
audioEl,
playing,
gain,
onGainChange,
onTogglePlay,
onPrev,
onNext,
playLabel,
pauseLabel,
prevLabel,
nextLabel,
volumeLabel,
scrubLabel,
emptyLabel,
}: PlaylistPlayerBarProps) {
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const scrubbing = useRef(false);
useEffect(() => {
if (!audioEl) {
setCurrent(0);
setDuration(0);
return;
}
const sync = () => {
if (!scrubbing.current) setCurrent(audioEl.currentTime);
setDuration(Number.isFinite(audioEl.duration) ? audioEl.duration : 0);
};
sync();
audioEl.addEventListener('timeupdate', sync);
audioEl.addEventListener('loadedmetadata', sync);
audioEl.addEventListener('durationchange', sync);
return () => {
audioEl.removeEventListener('timeupdate', sync);
audioEl.removeEventListener('loadedmetadata', sync);
audioEl.removeEventListener('durationchange', sync);
};
}, [audioEl]);
useEffect(() => {
if (audioEl) audioEl.volume = clampGain(gain);
}, [audioEl, gain]);
const hasTrack = Boolean(audioEl);
return (
<div className={styles.bar} data-testid="playlist-player-bar">
<div className={styles.meta}>
<div className={styles.title}>{hasTrack ? title : emptyLabel}</div>
{hasTrack && subtitle ? <div className={styles.subtitle}>{subtitle}</div> : null}
</div>
<div className={styles.transport}>
<Button variant="ghost" iconOnly title={prevLabel} ariaLabel={prevLabel} disabled={!hasTrack} onClick={onPrev}>
⏮
</Button>
<Button
variant="primary"
iconOnly
title={playing ? pauseLabel : playLabel}
ariaLabel={playing ? pauseLabel : playLabel}
disabled={!hasTrack}
onClick={onTogglePlay}
data-testid="playlist-play-pause"
>
{playing ? '⏸' : '▶'}
</Button>
<Button variant="ghost" iconOnly title={nextLabel} ariaLabel={nextLabel} disabled={!hasTrack} onClick={onNext}>
⏭
</Button>
</div>
<div className={styles.scrubRow}>
<span className={styles.time}>{formatTime(current)}</span>
<input
type="range"
className={styles.scrub}
min={0}
max={Math.max(duration, 0.001)}
step={0.01}
value={Math.min(current, duration || 0)}
disabled={!hasTrack || !duration}
aria-label={scrubLabel}
onMouseDown={() => {
scrubbing.current = true;
}}
onMouseUp={(e) => {
scrubbing.current = false;
if (audioEl) audioEl.currentTime = Number(e.currentTarget.value);
}}
onChange={(e) => {
const v = Number(e.currentTarget.value);
setCurrent(v);
if (audioEl && !scrubbing.current) audioEl.currentTime = v;
}}
/>
<span className={styles.time}>{formatTime(duration)}</span>
</div>
<label className={styles.volume}>
<span className={styles.volumeLabel}>{volumeLabel}</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={clampGain(gain)}
aria-label={volumeLabel}
onChange={(e) => onGainChange(clampGain(Number(e.currentTarget.value)))}
/>
</label>
</div>
);
}
@@ -0,0 +1,152 @@
.root {
display: grid;
grid-template-columns: minmax(200px, 260px) 1fr;
gap: 12px;
min-height: 0;
flex: 1;
height: 100%;
}
.sidebar,
.main {
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.mainDropActive {
outline: 2px dashed var(--accent2);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
.dropHintOverlay {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
pointer-events: none;
background: var(--color-overlay-dark-3);
color: var(--text);
font-size: var(--text-sm);
font-weight: 700;
border-radius: var(--radius-sm);
}
.dropHintMuted {
margin-top: 8px;
color: var(--text2);
font-size: var(--text-xs);
font-weight: 400;
}
.toolbar {
display: grid;
gap: 8px;
}
.list,
.tracks {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
padding: 6px;
display: grid;
gap: 4px;
align-content: start;
}
.playlistRow,
.trackRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.playlistRow:hover,
.trackRow:hover {
background: var(--accent-fill-soft);
}
.playlistRowSelected,
.trackRowCurrent {
background: var(--scene-list-selected-bg);
border: 1px solid var(--scene-list-selected-border);
}
.trackRowMatch {
outline: 1px dashed var(--accent2);
}
.playlistName,
.trackTitle,
.playlistNameReadonly,
.trackTitleReadonly {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
}
.trackTitle {
background: transparent;
border: 0;
color: inherit;
text-align: left;
cursor: text;
padding: 0;
}
.playlistNameReadonly {
font-weight: 700;
font-size: var(--text-md, 1rem);
}
.trackTitleReadonly {
color: inherit;
}
.playlistCount {
font-size: var(--text-xs);
color: var(--text2);
}
.mainHeader {
display: grid;
gap: 8px;
}
.mainActions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.trackPlay {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
padding: 0 4px;
}
.empty {
padding: 12px;
color: var(--text2);
font-size: var(--text-sm);
}
@@ -0,0 +1,564 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type {
AppPlaylist,
PlaylistAudioId,
PlaylistId,
PlaylistTrackId,
} from '../../../shared/types/appPlaylists';
import { filterPlaylistsByQuery } from '../../../shared/types/appPlaylists';
import { filterAudioFilePaths, useFileDropZone } from '../../editor/fileDrop';
import editorStyles from '../../editor/EditorApp.module.css';
import { useEditorI18n } from '../../editor/i18n/EditorI18nContext';
import { getDndApi } from '../dndApi';
import { Button, Input } from '../ui/controls';
import { PlaylistPlayerBar } from './PlaylistPlayerBar';
import styles from './PlaylistsPanel.module.css';
import { useAppPlaylists } from './useAppPlaylists';
const TRACK_DND = 'application/x-dnd-playlist-track';
const PLAYLIST_DND = 'application/x-dnd-playlist-id';
export type PlaylistsPanelLabels = {
searchPlaceholder: string;
create: string;
rename: string;
duplicate: string;
delete: string;
addAudio: string;
emptyPlaylists: string;
emptyTracks: string;
untitled: string;
play: string;
pause: string;
prev: string;
next: string;
volume: string;
scrub: string;
emptyPlayer: string;
playPlaylist: string;
removeTrack: string;
renameAudio: string;
dropAudioHint: string;
};
export function PlaylistsPanel({
labels,
className,
readOnly = false,
}: {
labels: PlaylistsPanelLabels;
className?: string;
/** Electron player window: browse + play only (no CRUD / DnD). */
readOnly?: boolean;
}) {
const { t } = useEditorI18n();
const api = getDndApi();
const state = useAppPlaylists();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<PlaylistId | null>(null);
const [editingName, setEditingName] = useState('');
const [editingAudioId, setEditingAudioId] = useState<PlaylistAudioId | null>(null);
const [editingAudioTitle, setEditingAudioTitle] = useState('');
const [pendingDelete, setPendingDelete] = useState<AppPlaylist | null>(null);
const audioById = useMemo(() => new Map(state.audios.map((a) => [a.id, a])), [state.audios]);
const filtered = useMemo(() => filterPlaylistsByQuery(state, query), [state, query]);
const selected = useMemo(
() => state.playlists.find((p) => p.id === selectedId) ?? filtered.playlists[0] ?? null,
[filtered.playlists, selectedId, state.playlists],
);
useEffect(() => {
if (selected) {
setSelectedId(selected.id);
setEditingName(selected.name);
} else {
setEditingName('');
}
}, [selected]);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [gain, setGain] = useState(1);
const [queuePlaylistId, setQueuePlaylistId] = useState<PlaylistId | null>(null);
const [queueIndex, setQueueIndex] = useState(0);
const queueRef = useRef<{ playlistId: PlaylistId; index: number } | null>(null);
const queuePlaylist = useMemo(
() => (queuePlaylistId ? state.playlists.find((p) => p.id === queuePlaylistId) ?? null : null),
[queuePlaylistId, state.playlists],
);
const currentTrack = queuePlaylist?.tracks[queueIndex] ?? null;
const currentAudio = currentTrack ? audioById.get(currentTrack.audioId) ?? null : null;
const stopPlayback = useCallback(() => {
const el = audioElRef.current;
if (el) {
el.pause();
el.removeAttribute('src');
el.load();
}
audioElRef.current = null;
setAudioEl(null);
setPlaying(false);
queueRef.current = null;
setQueuePlaylistId(null);
setQueueIndex(0);
}, []);
useEffect(() => () => stopPlayback(), [stopPlayback]);
const playAt = useCallback(
async (playlist: AppPlaylist, index: number) => {
const track = playlist.tracks[index];
if (!track) return;
const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId });
if (!url) return;
let el = audioElRef.current;
if (!el) {
el = new Audio();
el.preload = 'auto';
audioElRef.current = el;
setAudioEl(el);
}
el.volume = gain;
el.src = url;
queueRef.current = { playlistId: playlist.id, index };
setQueuePlaylistId(playlist.id);
setQueueIndex(index);
try {
await el.play();
setPlaying(true);
} catch {
setPlaying(false);
}
},
[api, gain],
);
useEffect(() => {
const el = audioElRef.current;
if (!el) return;
const onEnded = () => {
const q = queueRef.current;
if (!q) return;
const pl = state.playlists.find((p) => p.id === q.playlistId);
if (!pl || pl.tracks.length === 0) {
stopPlayback();
return;
}
const next = (q.index + 1) % pl.tracks.length;
void playAt(pl, next);
};
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
el.addEventListener('ended', onEnded);
el.addEventListener('play', onPlay);
el.addEventListener('pause', onPause);
return () => {
el.removeEventListener('ended', onEnded);
el.removeEventListener('play', onPlay);
el.removeEventListener('pause', onPause);
};
}, [audioEl, playAt, state.playlists, stopPlayback]);
const step = (delta: number) => {
const q = queueRef.current;
const pl = q ? state.playlists.find((p) => p.id === q.playlistId) : selected;
if (!pl || pl.tracks.length === 0) return;
const base = q?.playlistId === pl.id ? q.index : 0;
const next = (base + delta + pl.tracks.length) % pl.tracks.length;
void playAt(pl, next);
};
const createPlaylist = async () => {
const { playlist } = await api.invoke(ipcChannels.playlists.create, {});
setSelectedId(playlist.id);
};
const saveRename = async () => {
if (!selected) return;
if (editingName.trim() === selected.name) return;
await api.invoke(ipcChannels.playlists.rename, { id: selected.id, name: editingName });
};
const addAudio = async () => {
if (!selected) return;
const { filePaths } = await api.invoke(ipcChannels.playlists.pickAudioFiles, {});
if (filePaths.length === 0) return;
await api.invoke(ipcChannels.playlists.addAudioFiles, {
playlistId: selected.id,
filePaths,
});
};
const addAudioFromPaths = useCallback(
(paths: string[]) => {
if (!selected || paths.length === 0) return;
void api.invoke(ipcChannels.playlists.addAudioFiles, {
playlistId: selected.id,
filePaths: paths,
});
},
[api, selected],
);
const audioFileDrop = useFileDropZone({
disabled: readOnly || !selected,
filterPaths: filterAudioFilePaths,
onDropPaths: addAudioFromPaths,
});
const deleteSelected = () => {
if (readOnly || !selected) return;
setPendingDelete(selected);
};
const confirmDelete = async () => {
if (!pendingDelete) return;
const id = pendingDelete.id;
setPendingDelete(null);
if (queueRef.current?.playlistId === id) stopPlayback();
await api.invoke(ipcChannels.playlists.delete, { id });
setSelectedId((cur) => (cur === id ? null : cur));
};
return (
<div className={[styles.root, className].filter(Boolean).join(' ')} data-testid="playlists-panel">
<div className={styles.sidebar}>
<div className={styles.toolbar}>
<Input
value={query}
onChange={setQuery}
placeholder={labels.searchPlaceholder}
data-testid="playlists-search"
/>
{!readOnly ? (
<Button variant="primary" onClick={() => void createPlaylist()} data-testid="playlists-create">
{labels.create}
</Button>
) : null}
</div>
<div className={styles.list}>
{filtered.playlists.length === 0 ? (
<div className={styles.empty}>{labels.emptyPlaylists}</div>
) : (
filtered.playlists.map((pl) => (
<button
key={pl.id}
type="button"
className={[styles.playlistRow, selected?.id === pl.id ? styles.playlistRowSelected : '']
.filter(Boolean)
.join(' ')}
data-testid={`playlist-row-${pl.id}`}
draggable={!readOnly}
onDragStart={
readOnly
? undefined
: (e) => {
e.dataTransfer.setData(PLAYLIST_DND, pl.id);
e.dataTransfer.effectAllowed = 'move';
}
}
onDragOver={
readOnly
? undefined
: (e) => {
if (e.dataTransfer.types.includes(PLAYLIST_DND)) e.preventDefault();
}
}
onDrop={
readOnly
? undefined
: (e) => {
e.preventDefault();
const dragged = e.dataTransfer.getData(PLAYLIST_DND) as PlaylistId;
if (!dragged || dragged === pl.id) return;
const ids = state.playlists.map((p) => p.id);
const from = ids.indexOf(dragged);
const to = ids.indexOf(pl.id);
if (from < 0 || to < 0) return;
const next = [...ids];
next.splice(from, 1);
next.splice(to, 0, dragged);
void api.invoke(ipcChannels.playlists.setOrder, { playlistIds: next });
}
}
onClick={() => setSelectedId(pl.id)}
>
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
<span className={styles.playlistCount}>{pl.tracks.length}</span>
</button>
))
)}
</div>
</div>
<div
className={[
styles.main,
!readOnly && selected && audioFileDrop.dragOver ? styles.mainDropActive : '',
]
.filter(Boolean)
.join(' ')}
onDragEnter={readOnly ? undefined : audioFileDrop.onDragEnter}
onDragLeave={readOnly ? undefined : audioFileDrop.onDragLeave}
onDragOver={readOnly ? undefined : audioFileDrop.onDragOver}
onDrop={readOnly ? undefined : audioFileDrop.onDrop}
>
{!readOnly && selected && audioFileDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{labels.dropAudioHint}</div>
) : null}
{selected ? (
<>
<div className={styles.mainHeader}>
{readOnly ? (
<div className={styles.playlistNameReadonly}>{selected.name || labels.untitled}</div>
) : (
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
)}
<div className={styles.mainActions}>
<Button
variant="ghost"
onClick={() => void playAt(selected, 0)}
disabled={selected.tracks.length === 0}
>
{labels.playPlaylist}
</Button>
{!readOnly ? (
<>
<Button variant="ghost" onClick={() => void addAudio()}>
{labels.addAudio}
</Button>
<Button
variant="ghost"
onClick={() => void api.invoke(ipcChannels.playlists.duplicate, { id: selected.id })}
>
{labels.duplicate}
</Button>
<Button variant="ghost" onClick={() => void deleteSelected()}>
{labels.delete}
</Button>
</>
) : null}
</div>
</div>
<div className={styles.tracks}>
{selected.tracks.length === 0 ? (
<div className={styles.empty}>
{labels.emptyTracks}
{!readOnly ? <div className={styles.dropHintMuted}>{labels.dropAudioHint}</div> : null}
</div>
) : (
selected.tracks.map((tr, idx) => {
const audio = audioById.get(tr.audioId);
const title = audio?.title ?? labels.untitled;
const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId);
const isCurrent =
queuePlaylistId === selected.id && queueIndex === idx && Boolean(currentTrack);
return (
<div
key={tr.id}
className={[
styles.trackRow,
isCurrent ? styles.trackRowCurrent : '',
highlighted ? styles.trackRowMatch : '',
]
.filter(Boolean)
.join(' ')}
draggable={!readOnly}
onDragStart={
readOnly
? undefined
: (e) => {
e.dataTransfer.setData(TRACK_DND, tr.id);
e.dataTransfer.effectAllowed = 'move';
}
}
onDragOver={
readOnly
? undefined
: (e) => {
if (e.dataTransfer.types.includes(TRACK_DND)) e.preventDefault();
}
}
onDrop={
readOnly
? undefined
: (e) => {
if (!e.dataTransfer.types.includes(TRACK_DND)) return;
e.preventDefault();
e.stopPropagation();
const dragged = e.dataTransfer.getData(TRACK_DND) as PlaylistTrackId;
if (!dragged || dragged === tr.id) return;
const ids = selected.tracks.map((t) => t.id);
const from = ids.indexOf(dragged);
const to = ids.indexOf(tr.id);
if (from < 0 || to < 0) return;
const next = [...ids];
next.splice(from, 1);
next.splice(to, 0, dragged);
void api.invoke(ipcChannels.playlists.setTracksOrder, {
playlistId: selected.id,
trackIds: next,
});
}
}
onDoubleClick={() => void playAt(selected, idx)}
>
<button type="button" className={styles.trackPlay} onClick={() => void playAt(selected, idx)}>
▶
</button>
{readOnly ? (
<span className={styles.trackTitleReadonly}>{title}</span>
) : editingAudioId === tr.audioId ? (
<Input
value={editingAudioTitle}
onChange={setEditingAudioTitle}
onBlur={() => {
void api.invoke(ipcChannels.playlists.renameAudio, {
audioId: tr.audioId,
title: editingAudioTitle,
});
setEditingAudioId(null);
}}
/>
) : (
<button
type="button"
className={styles.trackTitle}
onClick={() => {
setEditingAudioId(tr.audioId);
setEditingAudioTitle(title);
}}
title={labels.renameAudio}
>
{title}
</button>
)}
{!readOnly ? (
<Button
variant="ghost"
onClick={() =>
void api.invoke(ipcChannels.playlists.removeTrack, {
playlistId: selected.id,
trackId: tr.id,
})
}
>
{labels.removeTrack}
</Button>
) : null}
</div>
);
})
)}
</div>
</>
) : (
<div className={styles.empty}>{labels.emptyPlaylists}</div>
)}
<PlaylistPlayerBar
title={currentAudio?.title ?? ''}
subtitle={queuePlaylist?.name}
audioEl={audioEl}
playing={playing}
gain={gain}
onGainChange={setGain}
onTogglePlay={() => {
const el = audioElRef.current;
if (!el) {
if (selected && selected.tracks.length > 0) void playAt(selected, 0);
return;
}
if (el.paused) void el.play().then(() => setPlaying(true)).catch(() => setPlaying(false));
else {
el.pause();
setPlaying(false);
}
}}
onPrev={() => step(-1)}
onNext={() => step(1)}
playLabel={labels.play}
pauseLabel={labels.pause}
prevLabel={labels.prev}
nextLabel={labels.next}
volumeLabel={labels.volume}
scrubLabel={labels.scrub}
emptyLabel={labels.emptyPlayer}
/>
</div>
{!readOnly && pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDelete(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="playlists-delete-confirm"
className={editorStyles.modalDialog}
>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('playlists.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalClose}
onClick={() => setPendingDelete(null)}
>
×
</button>
</div>
<div className={editorStyles.muted}>
{t('playlists.deleteConfirm', { name: pendingDelete.name })}
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={() => void confirmDelete()}>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}
export function playlistsPanelLabelsFromT(t: (key: string) => string): PlaylistsPanelLabels {
return {
searchPlaceholder: t('playlists.search'),
create: t('playlists.create'),
rename: t('playlists.rename'),
duplicate: t('playlists.duplicate'),
delete: t('playlists.delete'),
addAudio: t('playlists.addAudio'),
emptyPlaylists: t('playlists.empty'),
emptyTracks: t('playlists.emptyTracks'),
untitled: t('playlists.untitled'),
play: t('playlists.play'),
pause: t('playlists.pause'),
prev: t('playlists.prev'),
next: t('playlists.next'),
volume: t('playlists.volume'),
scrub: t('playlists.scrub'),
emptyPlayer: t('playlists.emptyPlayer'),
playPlaylist: t('playlists.playPlaylist'),
removeTrack: t('playlists.removeTrack'),
renameAudio: t('playlists.renameAudio'),
dropAudioHint: t('playlists.dropAudioHint'),
};
}
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { PlaylistsState } from '../../../shared/types/appPlaylists';
import { getDndApi } from '../dndApi';
const EMPTY: PlaylistsState = { playlists: [], audios: [] };
export function useAppPlaylists(): PlaylistsState {
const api = getDndApi();
const [state, setState] = useState<PlaylistsState>(EMPTY);
useEffect(() => {
void api.invoke(ipcChannels.playlists.getState, {}).then(setState);
return api.on(ipcChannels.playlists.stateChanged, (next) => setState(next));
}, [api]);
return state;
}
@@ -12,6 +12,7 @@ const SECONDARY_WINDOW_MAINS = [
'npcs/npcsEditorMain.tsx',
'npcs/npcsMain.tsx',
'materials/main.tsx',
'playlists/main.tsx',
'sceneEditor/main.tsx',
'sceneDescription/main.tsx',
'control/main.tsx',
@@ -125,6 +126,20 @@ void test('EditorApp: split Run button and launch-with-players modal', () => {
assert.ok(editor.includes('top.runWithPlayers'));
});
void test('EditorApp: Playlists header and modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="playlists-header-btn"'));
assert.ok(src.includes('PlaylistsManagerModal'));
assert.ok(src.includes('setPlaylistsManagerOpen(true)'));
assert.ok(src.includes('setPlaylistsManagerOpen(false)'));
});
void test('ControlApp: Playlists tool opens window', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="control-playlists-tool"'));
assert.ok(src.includes('windows.openPlaylists'));
});
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
const css = fs.readFileSync(
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
+2
View File
@@ -22,6 +22,7 @@ export type AppWindowKind =
| 'boot'
| 'sceneDescription'
| 'materials'
| 'playlists'
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
@@ -34,6 +35,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
boot: { ru: 'Загрузка', en: 'Loading' },
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' },
playlists: { ru: 'Плейлисты', en: 'Playlists' },
npcsEditor: { ru: 'НПС', en: 'NPCs' },
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
+82
View File
@@ -20,6 +20,12 @@ import type {
AppToken,
AppPlayer,
AppPlayerTeam,
AppPlaylist,
PlaylistAudio,
PlaylistAudioId,
PlaylistId,
PlaylistTrackId,
PlaylistsState,
PlayerId,
PlayerTeamId,
PlayerImageOffset,
@@ -149,6 +155,8 @@ export const ipcChannels = {
sceneDescriptionContent: 'windows.sceneDescriptionContent',
openMaterials: 'windows.openMaterials',
closeMaterials: 'windows.closeMaterials',
openPlaylists: 'windows.openPlaylists',
closePlaylists: 'windows.closePlaylists',
openNpcsEditor: 'windows.openNpcsEditor',
closeNpcsEditor: 'windows.closeNpcsEditor',
openNpcs: 'windows.openNpcs',
@@ -243,6 +251,21 @@ export const ipcChannels = {
upsertProgress: 'players.upsertProgress',
stateChanged: 'players.stateChanged',
},
playlists: {
getState: 'playlists.getState',
create: 'playlists.create',
rename: 'playlists.rename',
duplicate: 'playlists.duplicate',
delete: 'playlists.delete',
setOrder: 'playlists.setOrder',
setTracksOrder: 'playlists.setTracksOrder',
addAudioFiles: 'playlists.addAudioFiles',
pickAudioFiles: 'playlists.pickAudioFiles',
removeTrack: 'playlists.removeTrack',
renameAudio: 'playlists.renameAudio',
audioUrl: 'playlists.audioUrl',
stateChanged: 'playlists.stateChanged',
},
sceneNpcTokensSession: {
getState: 'sceneNpcTokensSession.getState',
dispatch: 'sceneNpcTokensSession.dispatch',
@@ -342,6 +365,7 @@ export type IpcEventMap = {
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.playlists.stateChanged]: PlaylistsState;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
@@ -667,6 +691,7 @@ export type IpcInvokeMap = {
storylineSelections: StorylineSelection[];
npcIds: string[];
labels: StorylineLabels;
playlistIds?: string[];
};
res: { canceled: true } | { canceled: false };
};
@@ -726,6 +751,14 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openPlaylists]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closePlaylists]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.openNpcsEditor]: {
req: Record<string, never>;
res: { ok: true };
@@ -914,6 +947,54 @@ export type IpcInvokeMap = {
req: { id: PlayerId };
res: { url: string | null };
};
[ipcChannels.playlists.getState]: {
req: Record<string, never>;
res: PlaylistsState;
};
[ipcChannels.playlists.create]: {
req: { name?: string };
res: { playlist: AppPlaylist; state: PlaylistsState };
};
[ipcChannels.playlists.rename]: {
req: { id: PlaylistId; name: string };
res: { playlist: AppPlaylist; state: PlaylistsState };
};
[ipcChannels.playlists.duplicate]: {
req: { id: PlaylistId };
res: { playlist: AppPlaylist; state: PlaylistsState };
};
[ipcChannels.playlists.delete]: {
req: { id: PlaylistId };
res: { state: PlaylistsState };
};
[ipcChannels.playlists.setOrder]: {
req: { playlistIds: PlaylistId[] };
res: { state: PlaylistsState };
};
[ipcChannels.playlists.setTracksOrder]: {
req: { playlistId: PlaylistId; trackIds: PlaylistTrackId[] };
res: { playlist: AppPlaylist; state: PlaylistsState };
};
[ipcChannels.playlists.addAudioFiles]: {
req: { playlistId: PlaylistId; filePaths: string[] };
res: { playlist: AppPlaylist; state: PlaylistsState };
};
[ipcChannels.playlists.pickAudioFiles]: {
req: Record<string, never>;
res: { filePaths: string[] };
};
[ipcChannels.playlists.removeTrack]: {
req: { playlistId: PlaylistId; trackId: PlaylistTrackId };
res: { playlist: AppPlaylist; state: PlaylistsState };
};
[ipcChannels.playlists.renameAudio]: {
req: { audioId: PlaylistAudioId; title: string };
res: { audio: PlaylistAudio; state: PlaylistsState };
};
[ipcChannels.playlists.audioUrl]: {
req: { id: PlaylistAudioId };
res: { url: string | null };
};
[ipcChannels.sceneNpcTokensSession.getState]: {
req: Record<string, never>;
res: { state: SceneNpcTokensSessionState };
@@ -996,6 +1077,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
[ipcChannels.playlists.stateChanged]: PlaylistsState;
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
+87
View File
@@ -0,0 +1,87 @@
/** App-local библиотека плейлистов (userData, не в project zip по умолчанию). */
import {
asPlaylistAudioId,
asPlaylistId,
asPlaylistTrackId,
type PlaylistAudioId,
type PlaylistId,
type PlaylistTrackId,
} from './ids';
export type { PlaylistAudioId, PlaylistId, PlaylistTrackId };
export { asPlaylistAudioId, asPlaylistId, asPlaylistTrackId };
/** Физический аудиофайл в библиотеке (один файл — много вхождений в плейлистах). */
export type PlaylistAudio = {
id: PlaylistAudioId;
/** Отображаемое имя (общее для всех вхождений). */
title: string;
originalName: string;
/** Относительный путь внутри `userData/playlists/`. */
relPath: string;
sha256: string;
mime: string;
sizeBytes: number;
};
/** Вхождение трека в конкретный плейлист. */
export type PlaylistTrack = {
id: PlaylistTrackId;
audioId: PlaylistAudioId;
};
export type AppPlaylist = {
id: PlaylistId;
name: string;
tracks: PlaylistTrack[];
};
export type PlaylistsState = {
playlists: AppPlaylist[];
audios: PlaylistAudio[];
};
/** Снимок для упаковки в экспорт проекта / импорта. */
export type PlaylistsExportBundle = {
version: 1;
playlists: AppPlaylist[];
audios: PlaylistAudio[];
/** Имя файла ассета → байты читаются отдельно в zip. */
};
export function normalizePlaylistName(name: string): string {
return name.trim() || 'Playlist';
}
export function titleFromAudioFileName(fileName: string): string {
const base = fileName.replace(/\.[^.]+$/u, '').trim();
return base || fileName.trim() || 'Track';
}
export function filterPlaylistsByQuery(
state: PlaylistsState,
query: string,
): { playlists: AppPlaylist[]; matchedAudioIds: Set<PlaylistAudioId> } {
const q = query.trim().toLowerCase();
if (!q) {
return { playlists: state.playlists, matchedAudioIds: new Set() };
}
const audioById = new Map(state.audios.map((a) => [a.id, a]));
const matchedAudioIds = new Set<PlaylistAudioId>();
const playlists: AppPlaylist[] = [];
for (const pl of state.playlists) {
const nameHit = pl.name.toLowerCase().includes(q);
let trackHit = false;
for (const tr of pl.tracks) {
const audio = audioById.get(tr.audioId);
if (!audio) continue;
if (audio.title.toLowerCase().includes(q) || audio.originalName.toLowerCase().includes(q)) {
matchedAudioIds.add(audio.id);
trackHit = true;
}
}
if (nameHit || trackHit) playlists.push(pl);
}
return { playlists, matchedAudioIds };
}
+15
View File
@@ -13,6 +13,9 @@ export type SceneTokenId = Brand<string, 'SceneTokenId'>;
export type PlayerId = Brand<string, 'PlayerId'>;
export type PlayerTeamId = Brand<string, 'PlayerTeamId'>;
export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
export type PlaylistId = Brand<string, 'PlaylistId'>;
export type PlaylistAudioId = Brand<string, 'PlaylistAudioId'>;
export type PlaylistTrackId = Brand<string, 'PlaylistTrackId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
@@ -65,3 +68,15 @@ export function asPlayerTeamId(value: string): PlayerTeamId {
export function asSceneNpcTokenId(value: string): SceneNpcTokenId {
return value as SceneNpcTokenId;
}
export function asPlaylistId(value: string): PlaylistId {
return value as PlaylistId;
}
export function asPlaylistAudioId(value: string): PlaylistAudioId {
return value as PlaylistAudioId;
}
export function asPlaylistTrackId(value: string): PlaylistTrackId {
return value as PlaylistTrackId;
}
+1
View File
@@ -1,4 +1,5 @@
export * from './appPlayers';
export * from './appPlaylists';
export * from './appTokens';
export * from './domain';
export * from './effects';