feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -279,10 +279,17 @@ function collectSceneAudioPaths(
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FoundryPendingAppPlaylist = {
|
||||||
|
name: string;
|
||||||
|
tracks: { title: string; absPath: string; mime: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
export type FoundryBuiltProject = {
|
export type FoundryBuiltProject = {
|
||||||
project: Project;
|
project: Project;
|
||||||
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
||||||
previewSources: { sceneId: SceneId; absPath: string }[];
|
previewSources: { sceneId: SceneId; absPath: string }[];
|
||||||
|
/** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */
|
||||||
|
pendingAppPlaylists: FoundryPendingAppPlaylist[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function buildProjectFromFoundryDocuments(
|
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'),
|
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
||||||
);
|
);
|
||||||
const campaignAudios: Project['campaignAudios'] = [];
|
const campaignAudios: Project['campaignAudios'] = [];
|
||||||
|
const pendingAppPlaylists: FoundryPendingAppPlaylist[] = [];
|
||||||
for (const pl of docs.playlists) {
|
for (const pl of docs.playlists) {
|
||||||
if (usedPlaylistIds.has(pl._id)) continue;
|
if (usedPlaylistIds.has(pl._id)) continue;
|
||||||
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||||
|
const tracks: FoundryPendingAppPlaylist['tracks'] = [];
|
||||||
for (const s of sounds) {
|
for (const s of sounds) {
|
||||||
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
||||||
const assetId = await resolveAndImport(ctx, manifest, s.path);
|
const abs = await resolveFoundryAssetPath(manifest, s.path);
|
||||||
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
|
if (!abs) continue;
|
||||||
campaignAudios.push({ assetId, autoplay: false, loop: true });
|
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);
|
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
|
||||||
@@ -538,7 +558,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
sceneGraphEdges,
|
sceneGraphEdges,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { project, previewSources };
|
return { project, previewSources, pendingAppPlaylists };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actor folders Foundry → дерево групп НПС. */
|
/** Actor folders Foundry → дерево групп НПС. */
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { ZipProjectStore } from './project/zipStore';
|
|||||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||||
import { PlayersStore } from './players/playersStore';
|
import { PlayersStore } from './players/playersStore';
|
||||||
|
import { PlaylistsStore } from './playlists/playlistsStore';
|
||||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||||
@@ -65,6 +66,7 @@ import {
|
|||||||
isMultiWindowOpen,
|
isMultiWindowOpen,
|
||||||
markAppQuitting,
|
markAppQuitting,
|
||||||
openMaterialsWindow,
|
openMaterialsWindow,
|
||||||
|
openPlaylistsWindow,
|
||||||
openMultiWindow,
|
openMultiWindow,
|
||||||
openNpcsEditorWindow,
|
openNpcsEditorWindow,
|
||||||
openSceneEditorWindow,
|
openSceneEditorWindow,
|
||||||
@@ -72,6 +74,7 @@ import {
|
|||||||
openNpcsWindow,
|
openNpcsWindow,
|
||||||
openSceneDescriptionWindow,
|
openSceneDescriptionWindow,
|
||||||
closeMaterialsWindow,
|
closeMaterialsWindow,
|
||||||
|
closePlaylistsWindow,
|
||||||
closeNpcsEditorWindow,
|
closeNpcsEditorWindow,
|
||||||
closeSceneEditorWindow,
|
closeSceneEditorWindow,
|
||||||
closeTokenPathEditorWindow,
|
closeTokenPathEditorWindow,
|
||||||
@@ -83,6 +86,7 @@ import {
|
|||||||
warmNpcsEditorWindow,
|
warmNpcsEditorWindow,
|
||||||
} from './windows/createWindows';
|
} from './windows/createWindows';
|
||||||
import { listDisplayInfos } from './windows/presentationDisplay';
|
import { listDisplayInfos } from './windows/presentationDisplay';
|
||||||
|
import { asPlaylistId } from '../shared/types/ids';
|
||||||
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
|
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
|
||||||
import { tokenPathTotalLength } from '../shared/types/tokenPath';
|
import { tokenPathTotalLength } from '../shared/types/tokenPath';
|
||||||
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
|
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
|
||||||
@@ -181,6 +185,7 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
|||||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||||
let tokensStore: TokensStore | null = null;
|
let tokensStore: TokensStore | null = null;
|
||||||
let playersStore: PlayersStore | null = null;
|
let playersStore: PlayersStore | null = null;
|
||||||
|
let playlistsStore: PlaylistsStore | null = null;
|
||||||
|
|
||||||
function emitTokenPathSessionState(): void {
|
function emitTokenPathSessionState(): void {
|
||||||
const state = tokenPathSessionStore.getState();
|
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 {
|
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
win.webContents.send(ipcChannels.players.upsertProgress, p);
|
win.webContents.send(ipcChannels.players.upsertProgress, p);
|
||||||
@@ -517,11 +530,13 @@ async function main() {
|
|||||||
playersStore = new PlayersStore(app.getPath('userData'));
|
playersStore = new PlayersStore(app.getPath('userData'));
|
||||||
await playersStore.ensureLoaded();
|
await playersStore.ensureLoaded();
|
||||||
}
|
}
|
||||||
|
playlistsStore = new PlaylistsStore(app.getPath('userData'));
|
||||||
|
await playlistsStore.ensureLoaded();
|
||||||
setLicenseAssert(() => {
|
setLicenseAssert(() => {
|
||||||
licenseService.assertForIpc();
|
licenseService.assertForIpc();
|
||||||
});
|
});
|
||||||
installAppMenuForSession();
|
installAppMenuForSession();
|
||||||
registerDndAssetProtocol(projectStore, tokensStore, playersStore);
|
registerDndAssetProtocol(projectStore, tokensStore, playersStore, playlistsStore);
|
||||||
registerHandler(ipcChannels.app.quit, () => {
|
registerHandler(ipcChannels.app.quit, () => {
|
||||||
markAppQuitting();
|
markAppQuitting();
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -623,6 +638,14 @@ async function main() {
|
|||||||
closeMaterialsWindow();
|
closeMaterialsWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.windows.openPlaylists, () => {
|
||||||
|
openPlaylistsWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.windows.closePlaylists, () => {
|
||||||
|
closePlaylistsWindow();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
||||||
openNpcsEditorWindow();
|
openNpcsEditorWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -1237,6 +1260,11 @@ async function main() {
|
|||||||
...(p.detail ? { detail: p.detail } : null),
|
...(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: 'Готово' });
|
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { canceled: false as const, project };
|
return { canceled: false as const, project };
|
||||||
@@ -1295,6 +1323,10 @@ async function main() {
|
|||||||
async (cacheDir) => {
|
async (cacheDir) => {
|
||||||
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
||||||
emitTokensState();
|
emitTokensState();
|
||||||
|
if (playlistsStore) {
|
||||||
|
await playlistsStore.importFromExportDir(cacheDir);
|
||||||
|
emitPlaylistsState();
|
||||||
|
}
|
||||||
return remap;
|
return remap;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1325,6 +1357,10 @@ async function main() {
|
|||||||
// Если в sourceCache нет app-tokens, remap пустой.
|
// Если в sourceCache нет app-tokens, remap пустой.
|
||||||
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
||||||
emitTokensState();
|
emitTokensState();
|
||||||
|
if (playlistsStore) {
|
||||||
|
await playlistsStore.importFromExportDir(cacheDir);
|
||||||
|
emitPlaylistsState();
|
||||||
|
}
|
||||||
return remap;
|
return remap;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1343,6 +1379,11 @@ async function main() {
|
|||||||
...(p.detail ? { detail: p.detail } : null),
|
...(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: 'Готово' });
|
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
@@ -1370,7 +1411,7 @@ async function main() {
|
|||||||
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
|
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
|
||||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
|
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
|
||||||
try {
|
try {
|
||||||
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
const { project, pendingAppPlaylists } = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
||||||
emitZipProgress({
|
emitZipProgress({
|
||||||
kind: 'import',
|
kind: 'import',
|
||||||
stage:
|
stage:
|
||||||
@@ -1379,6 +1420,16 @@ async function main() {
|
|||||||
...(p.detail ? { detail: p.detail } : null),
|
...(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: 'Готово' });
|
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
return { project };
|
return { project };
|
||||||
@@ -1389,7 +1440,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.project.exportZip,
|
ipcChannels.project.exportZip,
|
||||||
async ({ projectId, storylineSelections, npcIds, labels }) => {
|
async ({ projectId, storylineSelections, npcIds, labels, playlistIds }) => {
|
||||||
const list = await projectStore.listProjects();
|
const list = await projectStore.listProjects();
|
||||||
const entry = list.find((p) => p.id === projectId);
|
const entry = list.find((p) => p.id === projectId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -1427,12 +1478,19 @@ async function main() {
|
|||||||
async (tokenIds, exportRoot) => {
|
async (tokenIds, exportRoot) => {
|
||||||
await tokensStore!.packForExport(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: 'Готово' });
|
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
|
||||||
return { canceled: false as const };
|
return { canceled: false as const };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1674,6 +1732,93 @@ async function main() {
|
|||||||
}
|
}
|
||||||
return { url: playersStore.getImageUrl(id) };
|
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, () => {
|
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
|
||||||
return { state: sceneNpcTokensSessionStore.getState() };
|
return { state: sceneNpcTokensSessionStore.getState() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { filterPlaylistsByQuery } from '../../shared/types/appPlaylists';
|
||||||
|
import { asPlaylistAudioId, asPlaylistId, asPlaylistTrackId } from '../../shared/types/ids';
|
||||||
|
|
||||||
|
import { PlaylistsStore } from './playlistsStore';
|
||||||
|
|
||||||
|
void test('filterPlaylistsByQuery: matches playlist name and track title', () => {
|
||||||
|
const state = {
|
||||||
|
playlists: [
|
||||||
|
{
|
||||||
|
id: asPlaylistId('p1'),
|
||||||
|
name: 'Battle',
|
||||||
|
tracks: [{ id: asPlaylistTrackId('t1'), audioId: asPlaylistAudioId('a1') }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: asPlaylistId('p2'),
|
||||||
|
name: 'Town',
|
||||||
|
tracks: [{ id: asPlaylistTrackId('t2'), audioId: asPlaylistAudioId('a2') }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audios: [
|
||||||
|
{
|
||||||
|
id: asPlaylistAudioId('a1'),
|
||||||
|
title: 'Drums',
|
||||||
|
originalName: 'drums.mp3',
|
||||||
|
relPath: 'files/a.mp3',
|
||||||
|
sha256: '1',
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
sizeBytes: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: asPlaylistAudioId('a2'),
|
||||||
|
title: 'Market',
|
||||||
|
originalName: 'market.mp3',
|
||||||
|
relPath: 'files/b.mp3',
|
||||||
|
sha256: '2',
|
||||||
|
mime: 'audio/mpeg',
|
||||||
|
sizeBytes: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const byName = filterPlaylistsByQuery(state, 'bat');
|
||||||
|
assert.equal(byName.playlists.length, 1);
|
||||||
|
assert.equal(byName.playlists[0]?.id, asPlaylistId('p1'));
|
||||||
|
|
||||||
|
const byTrack = filterPlaylistsByQuery(state, 'market');
|
||||||
|
assert.equal(byTrack.playlists.length, 1);
|
||||||
|
assert.ok(byTrack.matchedAudioIds.has(asPlaylistAudioId('a2')));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('PlaylistsStore: create playlist and add audio by hash reuse', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-pl-'));
|
||||||
|
const store = new PlaylistsStore(dir);
|
||||||
|
await store.ensureLoaded();
|
||||||
|
const pl = await store.createPlaylist('Test');
|
||||||
|
const audioPath = path.join(dir, 'sample.mp3');
|
||||||
|
fs.writeFileSync(audioPath, Buffer.from('ID3fake-audio-bytes'));
|
||||||
|
|
||||||
|
await store.addAudioFiles(pl.id, [audioPath]);
|
||||||
|
await store.addAudioFiles(pl.id, [audioPath]);
|
||||||
|
const state = store.getState();
|
||||||
|
assert.equal(state.audios.length, 1);
|
||||||
|
assert.equal(state.playlists[0]?.tracks.length, 2);
|
||||||
|
assert.equal(state.playlists[0]?.tracks[0]?.audioId, state.playlists[0]?.tracks[1]?.audioId);
|
||||||
|
});
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AppPlaylist,
|
||||||
|
PlaylistAudio,
|
||||||
|
PlaylistTrack,
|
||||||
|
PlaylistsExportBundle,
|
||||||
|
PlaylistsState,
|
||||||
|
} from '../../shared/types/appPlaylists';
|
||||||
|
import {
|
||||||
|
normalizePlaylistName,
|
||||||
|
titleFromAudioFileName,
|
||||||
|
} from '../../shared/types/appPlaylists';
|
||||||
|
import {
|
||||||
|
asPlaylistAudioId,
|
||||||
|
asPlaylistId,
|
||||||
|
asPlaylistTrackId,
|
||||||
|
type PlaylistAudioId,
|
||||||
|
type PlaylistId,
|
||||||
|
type PlaylistTrackId,
|
||||||
|
} from '../../shared/types/ids';
|
||||||
|
|
||||||
|
type PlaylistsManifest = {
|
||||||
|
playlists: AppPlaylist[];
|
||||||
|
audios: PlaylistAudio[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
|
||||||
|
|
||||||
|
function mimeFromExt(ext: string): string {
|
||||||
|
const e = ext.toLowerCase();
|
||||||
|
if (e === '.mp3') return 'audio/mpeg';
|
||||||
|
if (e === '.wav') return 'audio/wav';
|
||||||
|
if (e === '.ogg') return 'audio/ogg';
|
||||||
|
if (e === '.m4a') return 'audio/mp4';
|
||||||
|
if (e === '.aac') return 'audio/aac';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileBase(name: string): string {
|
||||||
|
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||||
|
return base || 'audio';
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPlaylistId(): PlaylistId {
|
||||||
|
return asPlaylistId(`pl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomAudioId(): PlaylistAudioId {
|
||||||
|
return asPlaylistAudioId(`pla_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomTrackId(): PlaylistTrackId {
|
||||||
|
return asPlaylistTrackId(`plt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePlaylistName(desired: string, playlists: AppPlaylist[], exceptId?: PlaylistId): string {
|
||||||
|
const base = normalizePlaylistName(desired);
|
||||||
|
const taken = new Set(
|
||||||
|
playlists.filter((p) => p.id !== exceptId).map((p) => p.name.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
if (!taken.has(base.toLowerCase())) return base;
|
||||||
|
let i = 2;
|
||||||
|
while (taken.has(`${base} (${String(i)})`.toLowerCase())) i += 1;
|
||||||
|
return `${base} (${String(i)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaylistsStore {
|
||||||
|
private readonly rootDir: string;
|
||||||
|
private readonly filesDir: string;
|
||||||
|
private readonly manifestPath: string;
|
||||||
|
private playlists: AppPlaylist[] = [];
|
||||||
|
private audios: PlaylistAudio[] = [];
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
constructor(userData: string) {
|
||||||
|
this.rootDir = path.join(userData, 'playlists');
|
||||||
|
this.filesDir = path.join(this.rootDir, 'files');
|
||||||
|
this.manifestPath = path.join(this.rootDir, 'playlists.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureLoaded(): Promise<void> {
|
||||||
|
if (this.loaded) return;
|
||||||
|
await fs.mkdir(this.filesDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as PlaylistsManifest;
|
||||||
|
this.audios = Array.isArray(parsed.audios) ? parsed.audios : [];
|
||||||
|
const audioIds = new Set(this.audios.map((a) => a.id));
|
||||||
|
this.playlists = (Array.isArray(parsed.playlists) ? parsed.playlists : []).map((p) => ({
|
||||||
|
id: asPlaylistId(String(p.id)),
|
||||||
|
name: normalizePlaylistName(String(p.name ?? '')),
|
||||||
|
tracks: (Array.isArray(p.tracks) ? p.tracks : [])
|
||||||
|
.filter((t) => audioIds.has(t.audioId))
|
||||||
|
.map((t) => ({
|
||||||
|
id: asPlaylistTrackId(String(t.id)),
|
||||||
|
audioId: asPlaylistAudioId(String(t.audioId)),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
this.playlists = [];
|
||||||
|
this.audios = [];
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): PlaylistsState {
|
||||||
|
return {
|
||||||
|
playlists: this.playlists.map((p) => ({ ...p, tracks: [...p.tracks] })),
|
||||||
|
audios: [...this.audios],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAudioReadInfo(id: PlaylistAudioId): { absPath: string; mime: string } | null {
|
||||||
|
const audio = this.audios.find((a) => a.id === id);
|
||||||
|
if (!audio) return null;
|
||||||
|
return {
|
||||||
|
absPath: path.join(this.rootDir, audio.relPath),
|
||||||
|
mime: audio.mime || mimeFromExt(path.extname(audio.relPath)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAudioUrl(id: PlaylistAudioId): string | null {
|
||||||
|
if (!this.getAudioReadInfo(id)) return null;
|
||||||
|
return `dnd://playlist-audio?id=${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
await fs.mkdir(this.rootDir, { recursive: true });
|
||||||
|
const manifest: PlaylistsManifest = { playlists: this.playlists, audios: this.audios };
|
||||||
|
await fs.writeFile(this.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlaylist(name?: string): Promise<AppPlaylist> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const pl: AppPlaylist = {
|
||||||
|
id: randomPlaylistId(),
|
||||||
|
name: uniquePlaylistName(name ?? 'Playlist', this.playlists),
|
||||||
|
tracks: [],
|
||||||
|
};
|
||||||
|
this.playlists = [pl, ...this.playlists];
|
||||||
|
await this.persist();
|
||||||
|
return pl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renamePlaylist(id: PlaylistId, name: string): Promise<AppPlaylist> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const idx = this.playlists.findIndex((p) => p.id === id);
|
||||||
|
if (idx < 0) throw new Error('Playlist not found');
|
||||||
|
const next = {
|
||||||
|
...this.playlists[idx]!,
|
||||||
|
name: uniquePlaylistName(name, this.playlists, id),
|
||||||
|
};
|
||||||
|
this.playlists = this.playlists.map((p, i) => (i === idx ? next : p));
|
||||||
|
await this.persist();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicatePlaylist(id: PlaylistId): Promise<AppPlaylist> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const src = this.playlists.find((p) => p.id === id);
|
||||||
|
if (!src) throw new Error('Playlist not found');
|
||||||
|
const copy: AppPlaylist = {
|
||||||
|
id: randomPlaylistId(),
|
||||||
|
name: uniquePlaylistName(`${src.name} copy`, this.playlists),
|
||||||
|
tracks: src.tracks.map((t) => ({ id: randomTrackId(), audioId: t.audioId })),
|
||||||
|
};
|
||||||
|
const at = this.playlists.findIndex((p) => p.id === id);
|
||||||
|
const next = [...this.playlists];
|
||||||
|
next.splice(at + 1, 0, copy);
|
||||||
|
this.playlists = next;
|
||||||
|
await this.persist();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlaylist(id: PlaylistId): Promise<void> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
this.playlists = this.playlists.filter((p) => p.id !== id);
|
||||||
|
await this.pruneOrphanAudios();
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise<void> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const map = new Map(this.playlists.map((p) => [p.id, p]));
|
||||||
|
const next: AppPlaylist[] = [];
|
||||||
|
for (const id of playlistIds) {
|
||||||
|
const p = map.get(id);
|
||||||
|
if (p) {
|
||||||
|
next.push(p);
|
||||||
|
map.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of map.values()) next.push(p);
|
||||||
|
this.playlists = next;
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTracksOrder(playlistId: PlaylistId, trackIds: PlaylistTrackId[]): Promise<AppPlaylist> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const pl = this.playlists.find((p) => p.id === playlistId);
|
||||||
|
if (!pl) throw new Error('Playlist not found');
|
||||||
|
const map = new Map(pl.tracks.map((t) => [t.id, t]));
|
||||||
|
const tracks: PlaylistTrack[] = [];
|
||||||
|
for (const id of trackIds) {
|
||||||
|
const t = map.get(id);
|
||||||
|
if (t) {
|
||||||
|
tracks.push(t);
|
||||||
|
map.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of map.values()) tracks.push(t);
|
||||||
|
const next = { ...pl, tracks };
|
||||||
|
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
|
||||||
|
await this.persist();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameAudio(audioId: PlaylistAudioId, title: string): Promise<PlaylistAudio> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const idx = this.audios.findIndex((a) => a.id === audioId);
|
||||||
|
if (idx < 0) throw new Error('Audio not found');
|
||||||
|
const next = { ...this.audios[idx]!, title: title.trim() || this.audios[idx]!.title };
|
||||||
|
this.audios = this.audios.map((a, i) => (i === idx ? next : a));
|
||||||
|
await this.persist();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTrack(playlistId: PlaylistId, trackId: PlaylistTrackId): Promise<AppPlaylist> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const pl = this.playlists.find((p) => p.id === playlistId);
|
||||||
|
if (!pl) throw new Error('Playlist not found');
|
||||||
|
const next = { ...pl, tracks: pl.tracks.filter((t) => t.id !== trackId) };
|
||||||
|
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
|
||||||
|
await this.pruneOrphanAudios();
|
||||||
|
await this.persist();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAudioFiles(playlistId: PlaylistId, filePaths: string[]): Promise<AppPlaylist> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const pl = this.playlists.find((p) => p.id === playlistId);
|
||||||
|
if (!pl) throw new Error('Playlist not found');
|
||||||
|
const tracks = [...pl.tracks];
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (!AUDIO_EXTS.has(ext)) continue;
|
||||||
|
const audio = await this.importOrReuseFile(filePath);
|
||||||
|
tracks.push({ id: randomTrackId(), audioId: audio.id });
|
||||||
|
}
|
||||||
|
const next = { ...pl, tracks };
|
||||||
|
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
|
||||||
|
await this.persist();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Импорт файла с известным содержимым (Foundry / zip bundle). */
|
||||||
|
async importAudioBuffer(input: {
|
||||||
|
preferredId?: PlaylistAudioId;
|
||||||
|
title: string;
|
||||||
|
originalName: string;
|
||||||
|
mime: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
}): Promise<PlaylistAudio> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const sha256 = crypto.createHash('sha256').update(input.buffer).digest('hex');
|
||||||
|
const existing = this.audios.find((a) => a.sha256 === sha256);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const id =
|
||||||
|
input.preferredId && !this.audios.some((a) => a.id === input.preferredId)
|
||||||
|
? input.preferredId
|
||||||
|
: randomAudioId();
|
||||||
|
const ext = path.extname(input.originalName).toLowerCase() || '.mp3';
|
||||||
|
const fileName = `${id}_${safeFileBase(input.title)}${AUDIO_EXTS.has(ext) ? ext : '.mp3'}`;
|
||||||
|
const abs = path.join(this.filesDir, fileName);
|
||||||
|
await fs.writeFile(abs, input.buffer);
|
||||||
|
const audio: PlaylistAudio = {
|
||||||
|
id,
|
||||||
|
title: input.title.trim() || titleFromAudioFileName(input.originalName),
|
||||||
|
originalName: input.originalName,
|
||||||
|
relPath: path.join('files', fileName).replace(/\\/gu, '/'),
|
||||||
|
sha256,
|
||||||
|
mime: input.mime || mimeFromExt(ext),
|
||||||
|
sizeBytes: input.buffer.length,
|
||||||
|
};
|
||||||
|
this.audios = [...this.audios, audio];
|
||||||
|
await this.persist();
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
async importBundle(
|
||||||
|
bundle: PlaylistsExportBundle,
|
||||||
|
readFile: (relPath: string) => Promise<Buffer>,
|
||||||
|
): Promise<{ importedPlaylistIds: PlaylistId[] }> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const audioIdMap = new Map<PlaylistAudioId, PlaylistAudioId>();
|
||||||
|
for (const a of bundle.audios ?? []) {
|
||||||
|
try {
|
||||||
|
const buf = await readFile(a.relPath);
|
||||||
|
const imported = await this.importAudioBuffer({
|
||||||
|
title: a.title,
|
||||||
|
originalName: a.originalName,
|
||||||
|
mime: a.mime,
|
||||||
|
buffer: buf,
|
||||||
|
});
|
||||||
|
audioIdMap.set(a.id, imported.id);
|
||||||
|
} catch {
|
||||||
|
/* skip missing file */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const importedPlaylistIds: PlaylistId[] = [];
|
||||||
|
for (const pl of bundle.playlists ?? []) {
|
||||||
|
const tracks: PlaylistTrack[] = [];
|
||||||
|
for (const t of pl.tracks ?? []) {
|
||||||
|
const mapped = audioIdMap.get(t.audioId);
|
||||||
|
if (!mapped) continue;
|
||||||
|
tracks.push({ id: randomTrackId(), audioId: mapped });
|
||||||
|
}
|
||||||
|
const created: AppPlaylist = {
|
||||||
|
id: randomPlaylistId(),
|
||||||
|
name: uniquePlaylistName(pl.name, this.playlists),
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
this.playlists = [...this.playlists, created];
|
||||||
|
importedPlaylistIds.push(created.id);
|
||||||
|
}
|
||||||
|
await this.persist();
|
||||||
|
return { importedPlaylistIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildExportBundle(playlistIds: PlaylistId[]): {
|
||||||
|
bundle: PlaylistsExportBundle;
|
||||||
|
fileAbsPaths: { relPath: string; absPath: string }[];
|
||||||
|
} {
|
||||||
|
const selected = this.playlists.filter((p) => playlistIds.includes(p.id));
|
||||||
|
const usedAudioIds = new Set<PlaylistAudioId>();
|
||||||
|
for (const p of selected) {
|
||||||
|
for (const t of p.tracks) usedAudioIds.add(t.audioId);
|
||||||
|
}
|
||||||
|
const audios = this.audios.filter((a) => usedAudioIds.has(a.id));
|
||||||
|
const fileAbsPaths = audios.map((a) => ({
|
||||||
|
relPath: a.relPath,
|
||||||
|
absPath: path.join(this.rootDir, a.relPath),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
bundle: { version: 1, playlists: selected, audios },
|
||||||
|
fileAbsPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async packForExport(playlistIds: PlaylistId[], exportRoot: string): Promise<void> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
if (playlistIds.length === 0) return;
|
||||||
|
const { bundle, fileAbsPaths } = this.buildExportBundle(playlistIds);
|
||||||
|
if (bundle.playlists.length === 0) return;
|
||||||
|
const outDir = path.join(exportRoot, 'app-playlists');
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
for (const f of fileAbsPaths) {
|
||||||
|
const dest = path.join(outDir, f.relPath);
|
||||||
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||||
|
await fs.copyFile(f.absPath, dest);
|
||||||
|
}
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(outDir, 'playlists.json'),
|
||||||
|
`${JSON.stringify(bundle, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFromExportDir(sourceCache: string): Promise<void> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const manifestPath = path.join(sourceCache, 'app-playlists', 'playlists.json');
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(manifestPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bundle: PlaylistsExportBundle;
|
||||||
|
try {
|
||||||
|
bundle = JSON.parse(raw) as PlaylistsExportBundle;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.importBundle(bundle, async (relPath) => {
|
||||||
|
return fs.readFile(path.join(sourceCache, 'app-playlists', relPath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async importOrReuseFile(filePath: string): Promise<PlaylistAudio> {
|
||||||
|
const buf = await fs.readFile(filePath);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
|
const existing = this.audios.find((a) => a.sha256 === sha256);
|
||||||
|
if (existing) return existing;
|
||||||
|
const originalName = path.basename(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return this.importAudioBuffer({
|
||||||
|
title: titleFromAudioFileName(originalName),
|
||||||
|
originalName,
|
||||||
|
mime: mimeFromExt(ext),
|
||||||
|
buffer: buf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pruneOrphanAudios(): Promise<void> {
|
||||||
|
const used = new Set<PlaylistAudioId>();
|
||||||
|
for (const p of this.playlists) {
|
||||||
|
for (const t of p.tracks) used.add(t.audioId);
|
||||||
|
}
|
||||||
|
const orphans = this.audios.filter((a) => !used.has(a.id));
|
||||||
|
this.audios = this.audios.filter((a) => used.has(a.id));
|
||||||
|
for (const a of orphans) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(path.join(this.rootDir, a.relPath));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -411,6 +411,10 @@ export class ZipProjectStore {
|
|||||||
return this.openProject?.project ?? null;
|
return this.openProject?.project ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOpenCacheDir(): string | null {
|
||||||
|
return this.openProject?.cacheDir ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
||||||
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
@@ -2039,7 +2043,7 @@ export class ZipProjectStore {
|
|||||||
async importProjectFromFoundry(
|
async importProjectFromFoundry(
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
onProgress?: (p: FoundryImportProgress) => void,
|
onProgress?: (p: FoundryImportProgress) => void,
|
||||||
): Promise<Project> {
|
): Promise<{ project: Project; pendingAppPlaylists: import('../foundry/foundryImport').FoundryPendingAppPlaylist[] }> {
|
||||||
await this.ensureRoots();
|
await this.ensureRoots();
|
||||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||||
try {
|
try {
|
||||||
@@ -2049,7 +2053,7 @@ export class ZipProjectStore {
|
|||||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
||||||
|
|
||||||
const { project } = await buildProjectFromFoundryDocuments(
|
const { project, pendingAppPlaylists } = await buildProjectFromFoundryDocuments(
|
||||||
loaded.manifest,
|
loaded.manifest,
|
||||||
loaded.docs,
|
loaded.docs,
|
||||||
cacheDir,
|
cacheDir,
|
||||||
@@ -2070,7 +2074,7 @@ export class ZipProjectStore {
|
|||||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||||
await this.enqueuePack(cacheDir, zipPath);
|
await this.enqueuePack(cacheDir, zipPath);
|
||||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||||
return this.openProject.project;
|
return { project: this.openProject.project, pendingAppPlaylists };
|
||||||
} finally {
|
} finally {
|
||||||
await loaded.cleanup();
|
await loaded.cleanup();
|
||||||
}
|
}
|
||||||
@@ -2184,6 +2188,7 @@ export class ZipProjectStore {
|
|||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||||
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
||||||
|
packAppPlaylists?: (exportRoot: string) => Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
||||||
await this.ensureRoots();
|
await this.ensureRoots();
|
||||||
@@ -2217,6 +2222,9 @@ export class ZipProjectStore {
|
|||||||
const tokenIds = collectTokenIdsFromProject(partial);
|
const tokenIds = collectTokenIdsFromProject(partial);
|
||||||
await packAppTokens(tokenIds, exportCache);
|
await packAppTokens(tokenIds, exportCache);
|
||||||
}
|
}
|
||||||
|
if (packAppPlaylists) {
|
||||||
|
await packAppPlaylists(exportCache);
|
||||||
|
}
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const toWrite: Project = {
|
const toWrite: Project = {
|
||||||
...partial,
|
...partial,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
|
|||||||
|
|
||||||
import { session } from 'electron';
|
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 { PlayersStore } from '../players/playersStore';
|
||||||
|
import type { PlaylistsStore } from '../playlists/playlistsStore';
|
||||||
import type { ZipProjectStore } from '../project/zipStore';
|
import type { ZipProjectStore } from '../project/zipStore';
|
||||||
import type { TokensStore } from '../tokens/tokensStore';
|
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(
|
export function registerDndAssetProtocol(
|
||||||
projectStore: ZipProjectStore,
|
projectStore: ZipProjectStore,
|
||||||
tokensStore: TokensStore,
|
tokensStore: TokensStore,
|
||||||
playersStore: PlayersStore | null,
|
playersStore: PlayersStore | null,
|
||||||
|
playlistsStore: PlaylistsStore | null = null,
|
||||||
): void {
|
): void {
|
||||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -96,6 +98,8 @@ export function registerDndAssetProtocol(
|
|||||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||||
} else if (url.hostname === 'player') {
|
} else if (url.hostname === 'player') {
|
||||||
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
||||||
|
} else if (url.hostname === 'playlist-audio') {
|
||||||
|
info = playlistsStore?.getAudioReadInfo(asPlaylistAudioId(id)) ?? null;
|
||||||
}
|
}
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type WindowKind =
|
|||||||
| 'control'
|
| 'control'
|
||||||
| 'sceneDescription'
|
| 'sceneDescription'
|
||||||
| 'materials'
|
| 'materials'
|
||||||
|
| 'playlists'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
@@ -54,6 +55,10 @@ let pendingSceneDescriptionHtml = '';
|
|||||||
const MATERIALS_WINDOW_WIDTH = 300;
|
const MATERIALS_WINDOW_WIDTH = 300;
|
||||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
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_WIDTH = 1400;
|
||||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||||
@@ -163,6 +168,7 @@ function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
|||||||
function closePlaySessionAuxiliaryWindows(): void {
|
function closePlaySessionAuxiliaryWindows(): void {
|
||||||
closeSceneDescriptionWindow();
|
closeSceneDescriptionWindow();
|
||||||
closeMaterialsWindow();
|
closeMaterialsWindow();
|
||||||
|
closePlaylistsWindow();
|
||||||
closeNpcsWindow();
|
closeNpcsWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +217,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'sceneDescription.html';
|
return 'sceneDescription.html';
|
||||||
case 'materials':
|
case 'materials':
|
||||||
return 'materials.html';
|
return 'materials.html';
|
||||||
|
case 'playlists':
|
||||||
|
return 'playlists.html';
|
||||||
case 'npcsEditor':
|
case 'npcsEditor':
|
||||||
return 'npcsEditor.html';
|
return 'npcsEditor.html';
|
||||||
case 'sceneEditor':
|
case 'sceneEditor':
|
||||||
@@ -286,6 +294,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
|||||||
if (kind === 'control') return { width: 1200, height: 800 };
|
if (kind === 'control') return { width: 1200, height: 800 };
|
||||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
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 === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||||
@@ -396,6 +405,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
if (
|
if (
|
||||||
kind === 'sceneDescription' ||
|
kind === 'sceneDescription' ||
|
||||||
kind === 'materials' ||
|
kind === 'materials' ||
|
||||||
|
kind === 'playlists' ||
|
||||||
kind === 'npcsEditor' ||
|
kind === 'npcsEditor' ||
|
||||||
kind === 'sceneEditor' ||
|
kind === 'sceneEditor' ||
|
||||||
kind === 'tokenPathEditor' ||
|
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 {
|
export function closeNpcsEditorWindow(): void {
|
||||||
const win = windows.get('npcsEditor');
|
const win = windows.get('npcsEditor');
|
||||||
if (win && !win.isDestroyed()) {
|
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 {
|
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||||
const parent = windows.get('editor');
|
const parent = windows.get('editor');
|
||||||
const { width, height } = win.getBounds();
|
const { width, height } = win.getBounds();
|
||||||
|
|||||||
@@ -1826,6 +1826,22 @@ export function ControlApp() {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconOnly
|
iconOnly
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
|||||||
import { PlayersManagerModal } from './PlayersModals';
|
import { PlayersManagerModal } from './PlayersModals';
|
||||||
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
||||||
import { PresentationSettingsModal } from './PresentationSettingsModal';
|
import { PresentationSettingsModal } from './PresentationSettingsModal';
|
||||||
|
import { PlaylistsManagerModal } from './PlaylistsModal';
|
||||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||||
import type { ProjectNoticeCode } from './state/projectState';
|
import type { ProjectNoticeCode } from './state/projectState';
|
||||||
@@ -161,6 +162,7 @@ export function EditorApp() {
|
|||||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||||
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
||||||
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
||||||
|
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
|
||||||
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
|
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
|
||||||
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
||||||
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
@@ -382,7 +384,10 @@ export function EditorApp() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presentationOpen) setLaunching(false);
|
if (presentationOpen) {
|
||||||
|
setLaunching(false);
|
||||||
|
setPlaylistsManagerOpen(false);
|
||||||
|
}
|
||||||
}, [presentationOpen]);
|
}, [presentationOpen]);
|
||||||
|
|
||||||
const currentProjectName = state.project?.meta.name ?? '';
|
const currentProjectName = state.project?.meta.name ?? '';
|
||||||
@@ -864,6 +869,23 @@ export function EditorApp() {
|
|||||||
{t('top.players')}
|
{t('top.players')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
||||||
<div className={styles.flex1} />
|
<div className={styles.flex1} />
|
||||||
{appVersionText ? (
|
{appVersionText ? (
|
||||||
@@ -1561,8 +1583,8 @@ export function EditorApp() {
|
|||||||
storylineLabels={storylineLabels}
|
storylineLabels={storylineLabels}
|
||||||
loadStorylines={loadProjectStorylines}
|
loadStorylines={loadProjectStorylines}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
onExport={async (projectId, selections, npcIds) => {
|
onExport={async (projectId, selections, npcIds, playlistIds) => {
|
||||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
|
await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImportSourceModal
|
<ImportSourceModal
|
||||||
@@ -1656,6 +1678,10 @@ export function EditorApp() {
|
|||||||
open={presentationSettingsOpen}
|
open={presentationSettingsOpen}
|
||||||
onClose={() => setPresentationSettingsOpen(false)}
|
onClose={() => setPresentationSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<PlaylistsManagerModal
|
||||||
|
open={playlistsManagerOpen}
|
||||||
|
onClose={() => setPlaylistsManagerOpen(false)}
|
||||||
|
/>
|
||||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||||
<>
|
<>
|
||||||
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
|
<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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../shared/graph/storylineExportImport';
|
} from '../../shared/graph/storylineExportImport';
|
||||||
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
||||||
import { Button, Select } from '../shared/ui/controls';
|
import { Button, Select } from '../shared/ui/controls';
|
||||||
|
import { useAppPlaylists } from '../shared/playlists/useAppPlaylists';
|
||||||
|
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
@@ -37,6 +38,7 @@ type ExportProjectModalProps = {
|
|||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
selections: StorylineSelection[],
|
selections: StorylineSelection[],
|
||||||
npcIds: string[],
|
npcIds: string[],
|
||||||
|
playlistIds: string[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,12 +52,14 @@ export function ExportProjectModal({
|
|||||||
onExport,
|
onExport,
|
||||||
}: ExportProjectModalProps) {
|
}: ExportProjectModalProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
|
const playlistsState = useAppPlaylists();
|
||||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const [selectedNpcIds, setSelectedNpcIds] = 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 [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -67,6 +71,7 @@ export function ExportProjectModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setSelectedNpcIds(new Set());
|
setSelectedNpcIds(new Set());
|
||||||
|
setSelectedPlaylistIds(new Set());
|
||||||
setStep('storylines');
|
setStep('storylines');
|
||||||
setNpcs([]);
|
setNpcs([]);
|
||||||
}, [initialProjectId, open]);
|
}, [initialProjectId, open]);
|
||||||
@@ -156,7 +161,7 @@ export function ExportProjectModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onExport(projectId, selectedSelections, npcIds);
|
await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -168,7 +173,7 @@ export function ExportProjectModal({
|
|||||||
|
|
||||||
const goNextFromStorylines = () => {
|
const goNextFromStorylines = () => {
|
||||||
if (!canContinueStorylines) return;
|
if (!canContinueStorylines) return;
|
||||||
if (npcs.length === 0) {
|
if (npcs.length === 0 && playlistsState.playlists.length === 0) {
|
||||||
runExport([]);
|
runExport([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -248,26 +253,58 @@ export function ExportProjectModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.fieldGrid}>
|
<div className={styles.fieldGrid}>
|
||||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
{npcs.length > 0 ? (
|
||||||
<div className={styles.storylineChecklist}>
|
<>
|
||||||
{npcs.map((n) => (
|
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||||
<label key={n.id} className={styles.storylineCheck}>
|
<div className={styles.storylineChecklist}>
|
||||||
<input
|
{npcs.map((n) => (
|
||||||
type="checkbox"
|
<label key={n.id} className={styles.storylineCheck}>
|
||||||
checked={selectedNpcIds.has(n.id)}
|
<input
|
||||||
disabled={saving}
|
type="checkbox"
|
||||||
onChange={() => toggleNpc(n.id)}
|
checked={selectedNpcIds.has(n.id)}
|
||||||
/>
|
disabled={saving}
|
||||||
<span>{n.name}</span>
|
onChange={() => toggleNpc(n.id)}
|
||||||
</label>
|
/>
|
||||||
))}
|
<span>{n.name}</span>
|
||||||
</div>
|
</label>
|
||||||
<Button
|
))}
|
||||||
disabled={saving || npcs.length === 0}
|
</div>
|
||||||
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
<Button
|
||||||
>
|
disabled={saving || npcs.length === 0}
|
||||||
{t('export.selectAllNpcs')}
|
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||||
</Button>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -289,7 +326,7 @@ export function ExportProjectModal({
|
|||||||
disabled={!canContinueStorylines || saving}
|
disabled={!canContinueStorylines || saving}
|
||||||
onClick={goNextFromStorylines}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'top.project': 'Проект',
|
'top.project': 'Проект',
|
||||||
'top.file': 'Файл',
|
'top.file': 'Файл',
|
||||||
'top.players': 'Игроки',
|
'top.players': 'Игроки',
|
||||||
|
'top.playlists': 'Плейлисты',
|
||||||
'top.backToProjects': 'К списку проектов',
|
'top.backToProjects': 'К списку проектов',
|
||||||
'top.appVersion': 'Версия приложения',
|
'top.appVersion': 'Версия приложения',
|
||||||
'top.run': 'Запустить',
|
'top.run': 'Запустить',
|
||||||
@@ -325,6 +326,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'export.npcsHint':
|
'export.npcsHint':
|
||||||
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
||||||
'export.selectAllNpcs': 'Отметить всех',
|
'export.selectAllNpcs': 'Отметить всех',
|
||||||
|
'export.playlists': 'Плейлисты',
|
||||||
|
'export.playlistsHint': 'По умолчанию не выбраны. Отметьте плейлисты, которые нужно упаковать в архив.',
|
||||||
'export.next': 'Далее',
|
'export.next': 'Далее',
|
||||||
'export.back': 'Назад',
|
'export.back': 'Назад',
|
||||||
'export.exporting': 'Экспорт…',
|
'export.exporting': 'Экспорт…',
|
||||||
@@ -459,6 +462,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'players.deleteTeamTitle': 'Удаление команды',
|
'players.deleteTeamTitle': 'Удаление команды',
|
||||||
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
|
'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.open': 'НПС',
|
||||||
'npcs.editorTitle': 'НПС',
|
'npcs.editorTitle': 'НПС',
|
||||||
'npcs.add': 'Добавить',
|
'npcs.add': 'Добавить',
|
||||||
@@ -619,6 +647,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.markerTool': 'Маркер',
|
'control.markerTool': 'Маркер',
|
||||||
'control.markerColor': 'Цвет маркера',
|
'control.markerColor': 'Цвет маркера',
|
||||||
'control.materialsTool': 'Материалы',
|
'control.materialsTool': 'Материалы',
|
||||||
|
'control.playlistsTool': 'Плейлисты',
|
||||||
'control.npcsTool': 'НПС',
|
'control.npcsTool': 'НПС',
|
||||||
'control.descriptionMissing': 'Описание отсутствует',
|
'control.descriptionMissing': 'Описание отсутствует',
|
||||||
'control.effects': 'ЭФФЕКТЫ',
|
'control.effects': 'ЭФФЕКТЫ',
|
||||||
@@ -761,6 +790,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'top.project': 'Project',
|
'top.project': 'Project',
|
||||||
'top.file': 'File',
|
'top.file': 'File',
|
||||||
'top.players': 'Players',
|
'top.players': 'Players',
|
||||||
|
'top.playlists': 'Playlists',
|
||||||
'top.backToProjects': 'Back to projects',
|
'top.backToProjects': 'Back to projects',
|
||||||
'top.appVersion': 'App version',
|
'top.appVersion': 'App version',
|
||||||
'top.run': 'Run',
|
'top.run': 'Run',
|
||||||
@@ -975,6 +1005,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'export.npcsHint':
|
'export.npcsHint':
|
||||||
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
||||||
'export.selectAllNpcs': 'Select all',
|
'export.selectAllNpcs': 'Select all',
|
||||||
|
'export.playlists': 'Playlists',
|
||||||
|
'export.playlistsHint': 'None selected by default. Check playlists to include in the archive.',
|
||||||
'export.next': 'Next',
|
'export.next': 'Next',
|
||||||
'export.back': 'Back',
|
'export.back': 'Back',
|
||||||
'export.exporting': 'Exporting…',
|
'export.exporting': 'Exporting…',
|
||||||
@@ -1111,6 +1143,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'players.deleteTeamConfirm':
|
'players.deleteTeamConfirm':
|
||||||
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
|
'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.open': 'NPCs',
|
||||||
'npcs.editorTitle': 'NPCs',
|
'npcs.editorTitle': 'NPCs',
|
||||||
'npcs.add': 'Add',
|
'npcs.add': 'Add',
|
||||||
@@ -1271,6 +1328,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.markerTool': 'Marker',
|
'control.markerTool': 'Marker',
|
||||||
'control.markerColor': 'Marker color',
|
'control.markerColor': 'Marker color',
|
||||||
'control.materialsTool': 'Materials',
|
'control.materialsTool': 'Materials',
|
||||||
|
'control.playlistsTool': 'Playlists',
|
||||||
'control.npcsTool': 'NPCs',
|
'control.npcsTool': 'NPCs',
|
||||||
'control.descriptionMissing': 'No description',
|
'control.descriptionMissing': 'No description',
|
||||||
'control.effects': 'EFFECTS',
|
'control.effects': 'EFFECTS',
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ type Actions = {
|
|||||||
storylineSelections: StorylineSelection[],
|
storylineSelections: StorylineSelection[],
|
||||||
npcIds: string[],
|
npcIds: string[],
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
|
playlistIds?: string[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -901,6 +902,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
storylineSelections: StorylineSelection[],
|
storylineSelections: StorylineSelection[],
|
||||||
npcIds: string[],
|
npcIds: string[],
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
|
playlistIds: string[] = [],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||||
@@ -908,6 +910,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
storylineSelections,
|
storylineSelections,
|
||||||
npcIds,
|
npcIds,
|
||||||
labels,
|
labels,
|
||||||
|
playlistIds,
|
||||||
});
|
});
|
||||||
if (res.canceled) return;
|
if (res.canceled) return;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/npcsEditorMain.tsx',
|
||||||
'npcs/npcsMain.tsx',
|
'npcs/npcsMain.tsx',
|
||||||
'materials/main.tsx',
|
'materials/main.tsx',
|
||||||
|
'playlists/main.tsx',
|
||||||
'sceneEditor/main.tsx',
|
'sceneEditor/main.tsx',
|
||||||
'sceneDescription/main.tsx',
|
'sceneDescription/main.tsx',
|
||||||
'control/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'));
|
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', () => {
|
void test('PlayerTokenView: name plate shows full text without ellipsis', () => {
|
||||||
const css = fs.readFileSync(
|
const css = fs.readFileSync(
|
||||||
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type AppWindowKind =
|
|||||||
| 'boot'
|
| 'boot'
|
||||||
| 'sceneDescription'
|
| 'sceneDescription'
|
||||||
| 'materials'
|
| 'materials'
|
||||||
|
| 'playlists'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
@@ -34,6 +35,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
|||||||
boot: { ru: 'Загрузка', en: 'Loading' },
|
boot: { ru: 'Загрузка', en: 'Loading' },
|
||||||
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
||||||
materials: { ru: 'Материалы', en: 'Materials' },
|
materials: { ru: 'Материалы', en: 'Materials' },
|
||||||
|
playlists: { ru: 'Плейлисты', en: 'Playlists' },
|
||||||
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
npcsEditor: { ru: 'НПС', en: 'NPCs' },
|
||||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import type {
|
|||||||
AppToken,
|
AppToken,
|
||||||
AppPlayer,
|
AppPlayer,
|
||||||
AppPlayerTeam,
|
AppPlayerTeam,
|
||||||
|
AppPlaylist,
|
||||||
|
PlaylistAudio,
|
||||||
|
PlaylistAudioId,
|
||||||
|
PlaylistId,
|
||||||
|
PlaylistTrackId,
|
||||||
|
PlaylistsState,
|
||||||
PlayerId,
|
PlayerId,
|
||||||
PlayerTeamId,
|
PlayerTeamId,
|
||||||
PlayerImageOffset,
|
PlayerImageOffset,
|
||||||
@@ -149,6 +155,8 @@ export const ipcChannels = {
|
|||||||
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
||||||
openMaterials: 'windows.openMaterials',
|
openMaterials: 'windows.openMaterials',
|
||||||
closeMaterials: 'windows.closeMaterials',
|
closeMaterials: 'windows.closeMaterials',
|
||||||
|
openPlaylists: 'windows.openPlaylists',
|
||||||
|
closePlaylists: 'windows.closePlaylists',
|
||||||
openNpcsEditor: 'windows.openNpcsEditor',
|
openNpcsEditor: 'windows.openNpcsEditor',
|
||||||
closeNpcsEditor: 'windows.closeNpcsEditor',
|
closeNpcsEditor: 'windows.closeNpcsEditor',
|
||||||
openNpcs: 'windows.openNpcs',
|
openNpcs: 'windows.openNpcs',
|
||||||
@@ -243,6 +251,21 @@ export const ipcChannels = {
|
|||||||
upsertProgress: 'players.upsertProgress',
|
upsertProgress: 'players.upsertProgress',
|
||||||
stateChanged: 'players.stateChanged',
|
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: {
|
sceneNpcTokensSession: {
|
||||||
getState: 'sceneNpcTokensSession.getState',
|
getState: 'sceneNpcTokensSession.getState',
|
||||||
dispatch: 'sceneNpcTokensSession.dispatch',
|
dispatch: 'sceneNpcTokensSession.dispatch',
|
||||||
@@ -342,6 +365,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||||
|
[ipcChannels.playlists.stateChanged]: PlaylistsState;
|
||||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||||
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
|
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
|
||||||
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
||||||
@@ -667,6 +691,7 @@ export type IpcInvokeMap = {
|
|||||||
storylineSelections: StorylineSelection[];
|
storylineSelections: StorylineSelection[];
|
||||||
npcIds: string[];
|
npcIds: string[];
|
||||||
labels: StorylineLabels;
|
labels: StorylineLabels;
|
||||||
|
playlistIds?: string[];
|
||||||
};
|
};
|
||||||
res: { canceled: true } | { canceled: false };
|
res: { canceled: true } | { canceled: false };
|
||||||
};
|
};
|
||||||
@@ -726,6 +751,14 @@ export type IpcInvokeMap = {
|
|||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.windows.openNpcsEditor]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
@@ -914,6 +947,54 @@ export type IpcInvokeMap = {
|
|||||||
req: { id: PlayerId };
|
req: { id: PlayerId };
|
||||||
res: { url: string | null };
|
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]: {
|
[ipcChannels.sceneNpcTokensSession.getState]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { state: SceneNpcTokensSessionState };
|
res: { state: SceneNpcTokensSessionState };
|
||||||
@@ -996,6 +1077,7 @@ export type LegacyIpcEventMap = {
|
|||||||
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
[ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean };
|
||||||
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
[ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] };
|
||||||
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
[ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent;
|
||||||
|
[ipcChannels.playlists.stateChanged]: PlaylistsState;
|
||||||
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
[ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState };
|
||||||
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
|
[ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState };
|
||||||
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
[ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ export type SceneTokenId = Brand<string, 'SceneTokenId'>;
|
|||||||
export type PlayerId = Brand<string, 'PlayerId'>;
|
export type PlayerId = Brand<string, 'PlayerId'>;
|
||||||
export type PlayerTeamId = Brand<string, 'PlayerTeamId'>;
|
export type PlayerTeamId = Brand<string, 'PlayerTeamId'>;
|
||||||
export type SceneNpcTokenId = Brand<string, 'SceneNpcTokenId'>;
|
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 {
|
export function asProjectId(value: string): ProjectId {
|
||||||
return value as ProjectId;
|
return value as ProjectId;
|
||||||
@@ -65,3 +68,15 @@ export function asPlayerTeamId(value: string): PlayerTeamId {
|
|||||||
export function asSceneNpcTokenId(value: string): SceneNpcTokenId {
|
export function asSceneNpcTokenId(value: string): SceneNpcTokenId {
|
||||||
return value as 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,4 +1,5 @@
|
|||||||
export * from './appPlayers';
|
export * from './appPlayers';
|
||||||
|
export * from './appPlaylists';
|
||||||
export * from './appTokens';
|
export * from './appTokens';
|
||||||
export * from './domain';
|
export * from './domain';
|
||||||
export * from './effects';
|
export * from './effects';
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
@@ -0,0 +1,111 @@
|
|||||||
|
# ВК: новости НРИ Плеер с 1.0.27
|
||||||
|
|
||||||
|
Обложки: `docs/vk-covers/vk-cover-0N-*.png` (16:9, без текста на картинке).
|
||||||
|
|
||||||
|
Публиковать по одной, интервал 1–2 дня, порядок 1→8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ловушки на карте · 1.0.27 · `vk-cover-01-traps.png`
|
||||||
|
|
||||||
|
Сундук-мимик больше не просто картинка в заметках.
|
||||||
|
|
||||||
|
В НРИ Плеер появились ловушки прямо на карте сцены.
|
||||||
|
|
||||||
|
Расставляете маркеры в редакторе сцены, а за столом — проявляете, активируете или обезвреживаете с пульта. У активации есть свой эффект и звук: взрыв, яд, стрелы и другие типы.
|
||||||
|
|
||||||
|
Игроки видят только то, что вы раскрыли. Вы — всю картину.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #TTRPG #DnD #мастер #настолки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Боевая сетка · 1.0.27 · `vk-cover-02-grid.png`
|
||||||
|
|
||||||
|
Квадраты или гексы — поверх карты, без сторонних программ.
|
||||||
|
|
||||||
|
На превью сцены можно включить боевую сетку: квадратную или гексагональную, с цветом и размером ячейки.
|
||||||
|
|
||||||
|
Сетка живёт вместе со сценой, видна на пульте и на экране игроков — удобно мерить ходы, не перекрывая атмосферу.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #TTRPG #сетка #DnD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Неигровые токены · 1.0.27 · `vk-cover-03-tokens.png`
|
||||||
|
|
||||||
|
Бочки, статуи, маркеры — библиотека на этом компьютере, расстановка в проекте.
|
||||||
|
|
||||||
|
Добавили библиотеку неигровых токенов: свои картинки, уникальные имена, перенос на карту перетаскиванием.
|
||||||
|
|
||||||
|
На карте — размер, поворот, сдвиг во время сессии. Очистка сцены и удаление с карты — без лишней возни.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #токены #мастерская
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Кисть Закрытия · 1.0.27 · `vk-cover-04-closing-brush.png`
|
||||||
|
|
||||||
|
Открыли карту «Кистью Открытия» — теперь можно снова укрыть тьмой.
|
||||||
|
|
||||||
|
Если сцена затемнена, игроки начинают в полной темноте. «Кисть Открытия» снимает тьму — новая «Кисть Закрытия» возвращает её обратно.
|
||||||
|
|
||||||
|
Туман войны под контролем мастера: раскрыли зал — спрятали коридор — и снова вперёд.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #эффекты #туманвойны
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Несколько материалов сразу · 1.0.27 · `vk-cover-05-materials.png`
|
||||||
|
|
||||||
|
Карта сокровищ, записка и чертёж — слоями на экране игроков.
|
||||||
|
|
||||||
|
Материалы кампании теперь можно показывать несколько сразу: оверлеи выравниваются с экраном презентации, легенда едет вместе с картинкой.
|
||||||
|
|
||||||
|
Удобно держать под рукой схемы и раздачи, не прыгая между вкладками.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #материалы #сессия
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Игроки и типы НПС · 1.0.28 · `vk-cover-06-players.png`
|
||||||
|
|
||||||
|
Круглые аватары за столом и характеры НПС: друг, нейтрал, враг…
|
||||||
|
|
||||||
|
Появилась библиотека игроков и запуск сессии с выбранной партией: круглые токены на карте, кольца, команды.
|
||||||
|
|
||||||
|
У НПС на карте — тип отношения (disposition): от дружелюбного до враждебного. С пульта — контекстное меню: статус, неактивность, карточка.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #игроки #НПС
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Видео как полноценная карта · 1.0.28 · `vk-cover-07-video-rotate.png`
|
||||||
|
|
||||||
|
Ролик больше не «просто фон»: сетка, ловушки, токены, эффекты и тьма.
|
||||||
|
|
||||||
|
Видео-сцены догнали картинки: редактор сцены, сетка, ловушки, токены, эффекты и затемнение работают так же.
|
||||||
|
|
||||||
|
Плюс поворот превью на 90° — и для картинки, и для видео. Токены, НПС и ловушки остаются «приклеенными» к кадру при повороте.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #видео #карта
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Привязка к сетке на пульте · 1.0.28 · `vk-cover-08-grid-snap.png`
|
||||||
|
|
||||||
|
Токены встают в квадрат или гекс — одним движением с пульта.
|
||||||
|
|
||||||
|
На превью пульта токены сессии можно привязывать к боевой сетке (квадрат / гекс).
|
||||||
|
|
||||||
|
Пока активна кисть эффектов, маркеры не перехватывают клики — сначала атмосфера, потом фигуры. Оверлеи материалов и НПС затемняются только когда окна открыты.
|
||||||
|
|
||||||
|
Скачать: https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #пульт #сетка
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# ВК: общий пост релиза 1.0.28
|
||||||
|
|
||||||
|
Обложка: `docs/vk-covers/vk-cover-release-1-0-28.png` (16:9, без текста на картинке).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Черновик поста
|
||||||
|
|
||||||
|
НРИ Плеер 1.0.28 — обновление для мастера за столом.
|
||||||
|
|
||||||
|
Что нового:
|
||||||
|
|
||||||
|
• Видео как полноценная карта — сетка, ловушки, токены, эффекты и затемнение работают так же, как на картинках
|
||||||
|
• Поворот сцены (картинка и видео) на 90° — маркеры остаются на своих местах кадра
|
||||||
|
• Библиотека игроков и запуск сессии с партией — круглые токены на карте
|
||||||
|
• Типы НПС (друг / нейтрал / враг и др.) и действия с пульта
|
||||||
|
• Привязка токенов к боевой сетке (квадрат / гекс) прямо с пульта
|
||||||
|
• Кисть Закрытия — вернуть тьму туда, где уже открыли карту
|
||||||
|
|
||||||
|
Скачать бесплатно: https://ttrpgplayer.ru
|
||||||
|
Windows · macOS · Linux
|
||||||
|
|
||||||
|
#НРИплеер #TTRPG #DnD #релиз #настолки #мастер
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Короткая версия (если нужен короткий пост)
|
||||||
|
|
||||||
|
НРИ Плеер 1.0.28: видео-сцены как карты, поворот превью с токенами, игроки за столом, типы НПС, snap к сетке и Кисть Закрытия.
|
||||||
|
|
||||||
|
https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #TTRPG #релиз
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# ВК: одна статья — всё с релиза 1.0.28 (3 августа 2026)
|
||||||
|
|
||||||
|
Срез: `chore: release v1.0.28` (7a25b18) → текущий `main`.
|
||||||
|
Сюда входит merge ветки игроков (4 августа) и всё, что после: видео-карты, snap, пути, сессионные НПС, карточки.
|
||||||
|
|
||||||
|
Номер следующей сборки в тексте не ставить, пока не выйдет релиз.
|
||||||
|
|
||||||
|
Ниже — единый текст. Копировать от «После релиза 1.0.28» до хештегов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
После релиза 1.0.28 в НРИ Плеере собрался целый слой «живого стола»: партия на карте, НПС как фигурки и как портреты, видео как полноценная сцена, туман войны в обе стороны, маршруты и то, что раньше нельзя было сделать, не заглядывая в редактор посреди боя.
|
||||||
|
|
||||||
|
Скачать бесплатно: https://ttrpgplayer.ru
|
||||||
|
Windows · macOS · Linux
|
||||||
|
|
||||||
|
Игроки за столом
|
||||||
|
|
||||||
|
В шапке редактора появился раздел «Игроки». Это библиотека на этом компьютере, не внутри файла кампании: живые люди за вашим столом, а не персонажи сюжета.
|
||||||
|
|
||||||
|
«Добавить игрока» — имя и картинка обязательны, пока файл оптимизируется, видно прогресс. Справа превью токена: круг, цветная рамка, аватар внутри. Аватар можно сдвинуть мышью и масштабировать колесом, цвет кольца выбираете сами, имя лежит на тёмной подложке.
|
||||||
|
|
||||||
|
Команды — плоские группы, без вложенности. Создали команду — перетащили игрока внутрь или в «Без команды».
|
||||||
|
|
||||||
|
Запуск с партией: стрелка справа на кнопке «Запустить» → «Запустить с игроками». Отмечаете людей и/или команды, выбор держится до конца сессии. На пульте появляются «Показать игроков» / «Скрыть игроков». Токены можно двигать и менять размер за жёлтый уголок. Общий масштаб круглых фигур игроков и НПС — ползунок «Размеры игр. токенов» над превью. При смене сцены токены игроков скрываются, чтобы не торчать на следующем кадре, пока вы сами их не вернёте.
|
||||||
|
|
||||||
|
НПС на карте: тип, кольцо, меню
|
||||||
|
|
||||||
|
Кампанийных НПС по-прежнему заводите в «Свойства игры» → «НПС». Новый слой — круглый токен на карте, как у игроков.
|
||||||
|
|
||||||
|
В «Редакторе сцены» аккордеон «НПС»: перетащили плитку на карту — персонаж стоит в проекте. У карточки в редакторе НПС поле «Тип»: враждебный, нейтральный или дружественный. От типа зависит цвет кольца (красное / жёлтое / зелёное). Неактивный токен на сессии сереет.
|
||||||
|
|
||||||
|
На пульте правый клик по токену НПС:
|
||||||
|
• неактивен — «Сделать активным»;
|
||||||
|
• активен — «Открыть информацию», смена типа, «Сделать неактивным»;
|
||||||
|
• если это копия только на эту сессию — «Убрать с карты»;
|
||||||
|
• если задан путь — показать/скрыть линию, стоп/продолжить, сброс на старт.
|
||||||
|
|
||||||
|
«Открыть информацию» поднимает карточку в окне НПС и не включает чекбокс «Показать».
|
||||||
|
|
||||||
|
Окно НПС на сессии: карточки, «Показать», ссылка, спавн
|
||||||
|
|
||||||
|
Кнопка с цветной иконкой человека в «Инструменты» на пульте.
|
||||||
|
|
||||||
|
Клик по фото или имени открывает карточку слева (имя и описание). Можно открыть несколько: список, разделители, прокрутка, повторный клик закрывает, стрелка у имени сворачивает.
|
||||||
|
|
||||||
|
Чекбокс «Показать» под плиткой — это большой аватар поверх сцены на пульте и у игроков, как материал. Это не кружок на сетке. Клик по плитке карточку открывает, аватар сам не ставит. «Закрыть всех» убирает только аватары. Лупы «+» / «−» в шапке масштабируют показанные портреты: выбрали лупу, кликнули по аватару на превью.
|
||||||
|
|
||||||
|
Поставить кружок на текущую карту до конца сессии: перетащить плитку на «Предпросмотр экрана» или «⋮» → «На карту». Копий сколько нужно. В проект они не пишутся: новый «Запустить» вернёт расстановку из редактора.
|
||||||
|
|
||||||
|
В редакторе НПС у персонажа необязательная «Внешняя ссылка» (http/https, «https://» можно не писать). На сессии у карточки вкладки «Описание» и «Внешняя ссылка»: страница открывается внутри окна — вики, World Anvil, документ с лором, не сворачивая плеер.
|
||||||
|
|
||||||
|
Аватар в карточке редактора: пустой серый круг — клик или перетаскивание файла (PNG, JPG, WebP). Если картинка уже есть — пан внутри круга, колесо масштаба, карандаш по наведению сменяет файл. Сохраняется в момент отпускания кнопки.
|
||||||
|
|
||||||
|
Видео как полноценная карта
|
||||||
|
|
||||||
|
Ролик больше не «фон под эффектами». Для видео доступен тот же «Редактор сцены», что и для картинки: боевая сетка, ловушки, неигровые токены, токены НПС. Эффекты кистью и «Затемнить сцену» работают поверх ролика. На пульте у видео остаются воспроизведение и полоса перемотки.
|
||||||
|
|
||||||
|
Поворот превью на 90° теперь и у картинки, и у видео. Токены, НПС и ловушки остаются на своих местах кадра — не «отваливаются» в угол после поворота.
|
||||||
|
|
||||||
|
Кисть Закрытия
|
||||||
|
|
||||||
|
Если у сцены включено «Затемнить сцену», игроки начинают в полной тьме. «Кисть Открытия» снимает тьму на пульте и на презентации сразу. «Кисть Закрытия» снова накрывает уже открытые участки. У игроков закрытое чёрное, у вас на превью — полутень, чтобы не потерять карту. Это не эффект «Тьма» в блоке действий. Состояние помнится, пока сессия идёт и вы возвращаетесь на ту же карточку сцены.
|
||||||
|
|
||||||
|
Привязка к сетке на пульте
|
||||||
|
|
||||||
|
Сетку по-прежнему настраиваете в редакторе сцены (квадрат или гекс, цвет, размер). На пульте над превью — «Привязка токенов к сетке»: неигровые токены, НПС и игроки при перетаскивании встают в клетки. Без сетки на сцене галочка ничего не делает.
|
||||||
|
|
||||||
|
Размер на превью: жёлтый уголок у конкретной фигурки до конца сессии. Ползунок «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС.
|
||||||
|
|
||||||
|
Пути движения
|
||||||
|
|
||||||
|
Неигровые токены и НПС умеют ехать по маршруту. Токены игроков — нет: их двигаете вы.
|
||||||
|
|
||||||
|
В «Редакторе сцены» ПКМ по маркеру → «Указать движение». Левый клик ставит точку, точки таскаются. ПКМ по точке — замкнуть, разомкнуть, удалить. Замкнутый путь можно зациклить.
|
||||||
|
|
||||||
|
Слева: длительность в секундах; один раз / туда-обратно / круг (круг только для замкнутого); старт при входе в сцену или с задержкой; ориентация по касательной или фиксированный угол. «Превью» в этом окне, «Обратить путь», «Очистить путь», «Сохранить». «Сбросить на старт пути» в меню маркера.
|
||||||
|
|
||||||
|
За столом движение стартует само при входе в сцену. Линию вы видите всегда; игрокам — только после «Показать путь». ПКМ: показать/скрыть путь, остановить/продолжить, сброс. Пока фигурка едет, тащить её нельзя — сначала стоп.
|
||||||
|
|
||||||
|
Что не путать за столом
|
||||||
|
|
||||||
|
Четыре слоя НПС: кружок на сетке (из редактора или «На карту»), большой аватар «Показать», карточка описания, линия пути. Игроки — отдельная библиотека и отдельный запуск. Видео-сцена ведёт себя как карта. Тьма открывается и закрывается двумя кистями.
|
||||||
|
|
||||||
|
Инструкция в редакторе обновлена: «Игроки», «НПС», «Неигровые токены», «Редактор сцены», «Пульт управления», «Эффекты».
|
||||||
|
|
||||||
|
https://ttrpgplayer.ru
|
||||||
|
#НРИплеер #TTRPG #DnD #мастер #настолки #игроки #НПС
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function Add-Upload {
|
|||||||
if ($map.ContainsKey($remoteName)) {
|
if ($map.ContainsKey($remoteName)) {
|
||||||
$prev = $map[$remoteName]
|
$prev = $map[$remoteName]
|
||||||
if (-not $prev.Equals($file.FullName, [StringComparison]::OrdinalIgnoreCase)) {
|
if (-not $prev.Equals($file.FullName, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
Write-Warn "upload name collision for $remoteName: keeping $($file.Name)"
|
Write-Warn "upload name collision for ${remoteName}: keeping $($file.Name)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$map[$remoteName] = $file.FullName
|
$map[$remoteName] = $file.FullName
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
control: path.resolve(__dirname, 'app/renderer/control.html'),
|
||||||
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
|
||||||
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
|
||||||
|
playlists: path.resolve(__dirname, 'app/renderer/playlists.html'),
|
||||||
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
|
sceneEditor: path.resolve(__dirname, 'app/renderer/sceneEditor.html'),
|
||||||
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
||||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||||
|
|||||||