diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index 8c83a2c..f2252d0 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -279,10 +279,17 @@ function collectSceneAudioPaths( return paths; } +export type FoundryPendingAppPlaylist = { + name: string; + tracks: { title: string; absPath: string; mime: string }[]; +}; + export type FoundryBuiltProject = { project: Project; /** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */ previewSources: { sceneId: SceneId; absPath: string }[]; + /** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */ + pendingAppPlaylists: FoundryPendingAppPlaylist[]; }; export async function buildProjectFromFoundryDocuments( @@ -406,15 +413,28 @@ export async function buildProjectFromFoundryDocuments( importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'), ); const campaignAudios: Project['campaignAudios'] = []; + const pendingAppPlaylists: FoundryPendingAppPlaylist[] = []; for (const pl of docs.playlists) { if (usedPlaylistIds.has(pl._id)) continue; const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)); + const tracks: FoundryPendingAppPlaylist['tracks'] = []; for (const s of sounds) { if (typeof s.path !== 'string' || !s.path.trim()) continue; - const assetId = await resolveAndImport(ctx, manifest, s.path); - if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue; - campaignAudios.push({ assetId, autoplay: false, loop: true }); + const abs = await resolveFoundryAssetPath(manifest, s.path); + if (!abs) continue; + const kind = classifyMediaPath(abs); + if (!kind || kind.type !== 'audio') continue; + const title = + (typeof s.name === 'string' && s.name.trim()) || + path.basename(s.path).replace(/\.[^.]+$/u, '') || + 'Track'; + tracks.push({ title, absPath: abs, mime: kind.mime }); } + if (tracks.length === 0) continue; + pendingAppPlaylists.push({ + name: (typeof pl.name === 'string' && pl.name.trim()) || 'Playlist', + tracks, + }); } const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders); @@ -538,7 +558,7 @@ export async function buildProjectFromFoundryDocuments( sceneGraphEdges, }; - return { project, previewSources }; + return { project, previewSources, pendingAppPlaylists }; } /** Actor folders Foundry → дерево групп НПС. */ diff --git a/app/main/index.ts b/app/main/index.ts index c49530f..f24f4ed 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -38,6 +38,7 @@ import { ZipProjectStore } from './project/zipStore'; import { SceneViewStore } from './sceneView/sceneViewStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { PlayersStore } from './players/playersStore'; +import { PlaylistsStore } from './playlists/playlistsStore'; import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore'; import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore'; import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore'; @@ -65,6 +66,7 @@ import { isMultiWindowOpen, markAppQuitting, openMaterialsWindow, + openPlaylistsWindow, openMultiWindow, openNpcsEditorWindow, openSceneEditorWindow, @@ -72,6 +74,7 @@ import { openNpcsWindow, openSceneDescriptionWindow, closeMaterialsWindow, + closePlaylistsWindow, closeNpcsEditorWindow, closeSceneEditorWindow, closeTokenPathEditorWindow, @@ -83,6 +86,7 @@ import { warmNpcsEditorWindow, } from './windows/createWindows'; import { listDisplayInfos } from './windows/presentationDisplay'; +import { asPlaylistId } from '../shared/types/ids'; import { TokenPathSessionStore } from './tokens/tokenPathSessionStore'; import { tokenPathTotalLength } from '../shared/types/tokenPath'; import type { TokenPathTargetKind } from '../shared/types/tokenPathSession'; @@ -181,6 +185,7 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore(); const tokenGridSnapSessionStore = new TokenGridSnapSessionStore(); let tokensStore: TokensStore | null = null; let playersStore: PlayersStore | null = null; +let playlistsStore: PlaylistsStore | null = null; function emitTokenPathSessionState(): void { const state = tokenPathSessionStore.getState(); @@ -317,6 +322,14 @@ function emitPlayersState(): void { } } +function emitPlaylistsState(): void { + if (!playlistsStore) return; + const state = playlistsStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.playlists.stateChanged, state); + } +} + function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.players.upsertProgress, p); @@ -517,11 +530,13 @@ async function main() { playersStore = new PlayersStore(app.getPath('userData')); await playersStore.ensureLoaded(); } + playlistsStore = new PlaylistsStore(app.getPath('userData')); + await playlistsStore.ensureLoaded(); setLicenseAssert(() => { licenseService.assertForIpc(); }); installAppMenuForSession(); - registerDndAssetProtocol(projectStore, tokensStore, playersStore); + registerDndAssetProtocol(projectStore, tokensStore, playersStore, playlistsStore); registerHandler(ipcChannels.app.quit, () => { markAppQuitting(); app.quit(); @@ -623,6 +638,14 @@ async function main() { closeMaterialsWindow(); return { ok: true }; }); + registerHandler(ipcChannels.windows.openPlaylists, () => { + openPlaylistsWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.closePlaylists, () => { + closePlaylistsWindow(); + return { ok: true }; + }); registerHandler(ipcChannels.windows.openNpcsEditor, () => { openNpcsEditorWindow(); return { ok: true }; @@ -1237,6 +1260,11 @@ async function main() { ...(p.detail ? { detail: p.detail } : null), }); }); + const cacheDir = projectStore.getOpenCacheDir(); + if (playlistsStore && cacheDir) { + await playlistsStore.importFromExportDir(cacheDir); + emitPlaylistsState(); + } emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitSessionState(); return { canceled: false as const, project }; @@ -1295,6 +1323,10 @@ async function main() { async (cacheDir) => { const remap = await tokensStore!.importFromExportDir(cacheDir); emitTokensState(); + if (playlistsStore) { + await playlistsStore.importFromExportDir(cacheDir); + emitPlaylistsState(); + } return remap; }, ); @@ -1325,6 +1357,10 @@ async function main() { // Если в sourceCache нет app-tokens, remap пустой. const remap = await tokensStore!.importFromExportDir(cacheDir); emitTokensState(); + if (playlistsStore) { + await playlistsStore.importFromExportDir(cacheDir); + emitPlaylistsState(); + } return remap; }, ); @@ -1343,6 +1379,11 @@ async function main() { ...(p.detail ? { detail: p.detail } : null), }); }); + const cacheDir = projectStore.getOpenCacheDir(); + if (playlistsStore && cacheDir) { + await playlistsStore.importFromExportDir(cacheDir); + emitPlaylistsState(); + } emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitSessionState(); return { project }; @@ -1370,7 +1411,7 @@ async function main() { registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => { emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' }); try { - const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => { + const { project, pendingAppPlaylists } = await projectStore.importProjectFromFoundry(sourcePath, (p) => { emitZipProgress({ kind: 'import', stage: @@ -1379,6 +1420,16 @@ async function main() { ...(p.detail ? { detail: p.detail } : null), }); }); + if (playlistsStore && pendingAppPlaylists.length > 0) { + for (const pl of pendingAppPlaylists) { + const created = await playlistsStore.createPlaylist(pl.name); + const paths = pl.tracks.map((t) => t.absPath); + if (paths.length > 0) { + await playlistsStore.addAudioFiles(created.id, paths); + } + } + emitPlaylistsState(); + } emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitSessionState(); return { project }; @@ -1389,7 +1440,7 @@ async function main() { }); registerHandler( ipcChannels.project.exportZip, - async ({ projectId, storylineSelections, npcIds, labels }) => { + async ({ projectId, storylineSelections, npcIds, labels, playlistIds }) => { const list = await projectStore.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { @@ -1427,12 +1478,19 @@ async function main() { async (tokenIds, exportRoot) => { await tokensStore!.packForExport(tokenIds, exportRoot); }, + async (exportRoot) => { + if (!playlistsStore || !playlistIds?.length) return; + await playlistsStore.packForExport( + playlistIds.map((id) => asPlaylistId(id)), + exportRoot, + ); + }, ); emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' }); return { canceled: false as const }; } catch (err) { const detail = err instanceof Error ? err.message : 'Ошибка экспорта'; - emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail }); + emitZipProgress({ kind: 'export', stage: 'done', percent: 0, detail }); throw err; } }, @@ -1674,6 +1732,93 @@ async function main() { } return { url: playersStore.getImageUrl(id) }; }); + + registerHandler(ipcChannels.playlists.getState, async () => { + if (!playlistsStore) return { playlists: [], audios: [] }; + await playlistsStore.ensureLoaded(); + return playlistsStore.getState(); + }); + registerHandler(ipcChannels.playlists.create, async ({ name }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const playlist = await playlistsStore.createPlaylist(name); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { playlist, state }; + }); + registerHandler(ipcChannels.playlists.rename, async ({ id, name }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const playlist = await playlistsStore.renamePlaylist(id, name); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { playlist, state }; + }); + registerHandler(ipcChannels.playlists.duplicate, async ({ id }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const playlist = await playlistsStore.duplicatePlaylist(id); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { playlist, state }; + }); + registerHandler(ipcChannels.playlists.delete, async ({ id }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + await playlistsStore.deletePlaylist(id); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { state }; + }); + registerHandler(ipcChannels.playlists.setOrder, async ({ playlistIds }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + await playlistsStore.setPlaylistsOrder(playlistIds); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { state }; + }); + registerHandler(ipcChannels.playlists.setTracksOrder, async ({ playlistId, trackIds }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const playlist = await playlistsStore.setTracksOrder(playlistId, trackIds); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { playlist, state }; + }); + registerHandler(ipcChannels.playlists.addAudioFiles, async ({ playlistId, filePaths }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const playlist = await playlistsStore.addAudioFiles(playlistId, filePaths); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { playlist, state }; + }); + registerHandler(ipcChannels.playlists.pickAudioFiles, async () => { + const win = BrowserWindow.getFocusedWindow(); + const { canceled, filePaths } = await dialog.showOpenDialog(win ?? undefined, { + properties: ['openFile', 'multiSelections'], + filters: [ + { + name: openDialogFilterLabel('audio', app.getLocale()), + extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'], + }, + ], + }); + if (canceled) return { filePaths: [] }; + return { filePaths }; + }); + registerHandler(ipcChannels.playlists.removeTrack, async ({ playlistId, trackId }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const playlist = await playlistsStore.removeTrack(playlistId, trackId); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { playlist, state }; + }); + registerHandler(ipcChannels.playlists.renameAudio, async ({ audioId, title }) => { + if (!playlistsStore) throw new Error('Playlists store unavailable'); + const audio = await playlistsStore.renameAudio(audioId, title); + const state = playlistsStore.getState(); + emitPlaylistsState(); + return { audio, state }; + }); + registerHandler(ipcChannels.playlists.audioUrl, ({ id }) => { + return { url: playlistsStore?.getAudioUrl(id) ?? null }; + }); + registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => { return { state: sceneNpcTokensSessionStore.getState() }; }); diff --git a/app/main/playlists/playlistsStore.test.ts b/app/main/playlists/playlistsStore.test.ts new file mode 100644 index 0000000..1dcb739 --- /dev/null +++ b/app/main/playlists/playlistsStore.test.ts @@ -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); +}); diff --git a/app/main/playlists/playlistsStore.ts b/app/main/playlists/playlistsStore.ts new file mode 100644 index 0000000..2bc1506 --- /dev/null +++ b/app/main/playlists/playlistsStore.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.ensureLoaded(); + this.playlists = this.playlists.filter((p) => p.id !== id); + await this.pruneOrphanAudios(); + await this.persist(); + } + + async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): Promise<{ importedPlaylistIds: PlaylistId[] }> { + await this.ensureLoaded(); + const audioIdMap = new Map(); + 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(); + 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 { + 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 { + 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 { + 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 { + const used = new Set(); + 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 */ + } + } + } +} diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 99c0556..e245347 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -411,6 +411,10 @@ export class ZipProjectStore { return this.openProject?.project ?? null; } + getOpenCacheDir(): string | null { + return this.openProject?.cacheDir ?? null; + } + /** Только указатель сессии в памяти — без записи project.json на диск. */ setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project { const open = this.openProject; @@ -2039,7 +2043,7 @@ export class ZipProjectStore { async importProjectFromFoundry( sourcePath: string, onProgress?: (p: FoundryImportProgress) => void, - ): Promise { + ): Promise<{ project: Project; pendingAppPlaylists: import('../foundry/foundryImport').FoundryPendingAppPlaylist[] }> { await this.ensureRoots(); const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress); try { @@ -2049,7 +2053,7 @@ export class ZipProjectStore { await fs.rm(cacheDir, { recursive: true, force: true }); await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true }); - const { project } = await buildProjectFromFoundryDocuments( + const { project, pendingAppPlaylists } = await buildProjectFromFoundryDocuments( loaded.manifest, loaded.docs, cacheDir, @@ -2070,7 +2074,7 @@ export class ZipProjectStore { onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' }); await this.enqueuePack(cacheDir, zipPath); onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' }); - return this.openProject.project; + return { project: this.openProject.project, pendingAppPlaylists }; } finally { await loaded.cleanup(); } @@ -2184,6 +2188,7 @@ export class ZipProjectStore { labels: StorylineLabels, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise, + packAppPlaylists?: (exportRoot: string) => Promise, ): Promise { if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия'); await this.ensureRoots(); @@ -2217,6 +2222,9 @@ export class ZipProjectStore { const tokenIds = collectTokenIdsFromProject(partial); await packAppTokens(tokenIds, exportCache); } + if (packAppPlaylists) { + await packAppPlaylists(exportCache); + } const now = new Date().toISOString(); const toWrite: Project = { ...partial, diff --git a/app/main/protocol/dndAssetProtocol.ts b/app/main/protocol/dndAssetProtocol.ts index 919002e..8425b00 100644 --- a/app/main/protocol/dndAssetProtocol.ts +++ b/app/main/protocol/dndAssetProtocol.ts @@ -2,8 +2,9 @@ import fs from 'node:fs/promises'; import { session } from 'electron'; -import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids'; +import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids'; import type { PlayersStore } from '../players/playersStore'; +import type { PlaylistsStore } from '../playlists/playlistsStore'; import type { ZipProjectStore } from '../project/zipStore'; import type { TokensStore } from '../tokens/tokensStore'; @@ -76,12 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise { } /** - * Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`. + * Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`. */ export function registerDndAssetProtocol( projectStore: ZipProjectStore, tokensStore: TokensStore, playersStore: PlayersStore | null, + playlistsStore: PlaylistsStore | null = null, ): void { session.defaultSession.protocol.handle('dnd', async (request) => { const url = new URL(request.url); @@ -96,6 +98,8 @@ export function registerDndAssetProtocol( info = tokensStore.getImageReadInfo(asTokenId(id)); } else if (url.hostname === 'player') { info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null; + } else if (url.hostname === 'playlist-audio') { + info = playlistsStore?.getAudioReadInfo(asPlaylistAudioId(id)) ?? null; } if (!info) { return new Response(null, { status: 404 }); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index db9fb2e..820ca95 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -20,6 +20,7 @@ export type WindowKind = | 'control' | 'sceneDescription' | 'materials' + | 'playlists' | 'npcsEditor' | 'sceneEditor' | 'tokenPathEditor' @@ -54,6 +55,10 @@ let pendingSceneDescriptionHtml = ''; const MATERIALS_WINDOW_WIDTH = 300; const MATERIALS_WINDOW_HEIGHT = 720; +/** Окно плейлистов на пульте. */ +const PLAYLISTS_WINDOW_WIDTH = 720; +const PLAYLISTS_WINDOW_HEIGHT = 800; + /** Редактор НПС — как основной редактор, шире инспектор. */ const NPCS_EDITOR_WINDOW_WIDTH = 1400; const NPCS_EDITOR_WINDOW_HEIGHT = 860; @@ -163,6 +168,7 @@ function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void { function closePlaySessionAuxiliaryWindows(): void { closeSceneDescriptionWindow(); closeMaterialsWindow(); + closePlaylistsWindow(); closeNpcsWindow(); } @@ -211,6 +217,8 @@ function pageNameForKind(kind: WindowKind): string { return 'sceneDescription.html'; case 'materials': return 'materials.html'; + case 'playlists': + return 'playlists.html'; case 'npcsEditor': return 'npcsEditor.html'; case 'sceneEditor': @@ -286,6 +294,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number } if (kind === 'control') return { width: 1200, height: 800 }; if (kind === 'sceneDescription') return { width: 720, height: 640 }; if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT }; + if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT }; if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT }; if (kind === 'sceneEditor') return { width: 1280, height: 800 }; if (kind === 'tokenPathEditor') return { width: 1100, height: 760 }; @@ -396,6 +405,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow if ( kind === 'sceneDescription' || kind === 'materials' || + kind === 'playlists' || kind === 'npcsEditor' || kind === 'sceneEditor' || kind === 'tokenPathEditor' || @@ -586,6 +596,13 @@ export function closeMaterialsWindow(): void { } } +export function closePlaylistsWindow(): void { + const win = windows.get('playlists'); + if (win && !win.isDestroyed()) { + win.close(); + } +} + export function closeNpcsEditorWindow(): void { const win = windows.get('npcsEditor'); if (win && !win.isDestroyed()) { @@ -745,6 +762,36 @@ export function openMaterialsWindow(): void { }); } +export function openPlaylistsWindow(): void { + const existing = windows.get('playlists'); + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + existing.moveTop(); + return; + } + + const parent = windows.get('control') ?? windows.get('presentation'); + const win = createWindow('playlists', parent ? { parent } : undefined); + const { width, height } = win.getBounds(); + const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); + const { x, y, width: dw, height: dh } = display.workArea; + win.setBounds({ + x: Math.round(x + Math.max(0, (dw - width) / 2)), + y: Math.round(y + Math.max(0, (dh - height) / 2)), + width, + height, + }); + win.webContents.once('did-finish-load', () => { + if (!win.isDestroyed()) { + win.show(); + win.focus(); + win.moveTop(); + } + }); +} + function positionNpcsEditorWindow(win: BrowserWindow): void { const parent = windows.get('editor'); const { width, height } = win.getBounds(); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index ea3aa97..81b7531 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1826,6 +1826,22 @@ export function ControlApp() { + ) : null} +
{appVersionText ? ( @@ -1561,8 +1583,8 @@ export function EditorApp() { storylineLabels={storylineLabels} loadStorylines={loadProjectStorylines} onClose={() => setExportModalOpen(false)} - onExport={async (projectId, selections, npcIds) => { - await actions.exportProject(projectId, selections, npcIds, storylineLabels); + onExport={async (projectId, selections, npcIds, playlistIds) => { + await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds); }} /> setPresentationSettingsOpen(false)} /> + setPlaylistsManagerOpen(false)} + /> {USERS_BRANCH_FEATURES_ENABLED ? ( <> setPlayersManagerOpen(false)} /> diff --git a/app/renderer/editor/PlaylistsModal.module.css b/app/renderer/editor/PlaylistsModal.module.css new file mode 100644 index 0000000..a4b2ea2 --- /dev/null +++ b/app/renderer/editor/PlaylistsModal.module.css @@ -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; +} diff --git a/app/renderer/editor/PlaylistsModal.tsx b/app/renderer/editor/PlaylistsModal.tsx new file mode 100644 index 0000000..e2a9947 --- /dev/null +++ b/app/renderer/editor/PlaylistsModal.tsx @@ -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( + <> +
+
+
+
{t('playlists.title')}
+ +
+
+ +
+
+ +
+
+ , + document.body, + ); +} diff --git a/app/renderer/editor/StorylineTransferModals.tsx b/app/renderer/editor/StorylineTransferModals.tsx index 89f329d..505a60e 100644 --- a/app/renderer/editor/StorylineTransferModals.tsx +++ b/app/renderer/editor/StorylineTransferModals.tsx @@ -18,6 +18,7 @@ import { } from '../../shared/graph/storylineExportImport'; import type { Project, ProjectId, SceneId } from '../../shared/types'; import { Button, Select } from '../shared/ui/controls'; +import { useAppPlaylists } from '../shared/playlists/useAppPlaylists'; import styles from './EditorApp.module.css'; import { useEditorI18n } from './i18n/EditorI18nContext'; @@ -37,6 +38,7 @@ type ExportProjectModalProps = { projectId: ProjectId, selections: StorylineSelection[], npcIds: string[], + playlistIds: string[], ) => Promise; }; @@ -50,12 +52,14 @@ export function ExportProjectModal({ onExport, }: ExportProjectModalProps) { const { t } = useEditorI18n(); + const playlistsState = useAppPlaylists(); const [step, setStep] = useState<'storylines' | 'npcs'>('storylines'); const [projectId, setProjectId] = useState(initialProjectId); const [storylines, setStorylines] = useState([]); const [npcs, setNpcs] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [selectedNpcIds, setSelectedNpcIds] = useState>(new Set()); + const [selectedPlaylistIds, setSelectedPlaylistIds] = useState>(new Set()); const [loadingStorylines, setLoadingStorylines] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -67,6 +71,7 @@ export function ExportProjectModal({ setError(null); setSelectedKeys(new Set()); setSelectedNpcIds(new Set()); + setSelectedPlaylistIds(new Set()); setStep('storylines'); setNpcs([]); }, [initialProjectId, open]); @@ -156,7 +161,7 @@ export function ExportProjectModal({ setSaving(true); setError(null); try { - await onExport(projectId, selectedSelections, npcIds); + await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]); onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -168,7 +173,7 @@ export function ExportProjectModal({ const goNextFromStorylines = () => { if (!canContinueStorylines) return; - if (npcs.length === 0) { + if (npcs.length === 0 && playlistsState.playlists.length === 0) { runExport([]); return; } @@ -248,26 +253,58 @@ export function ExportProjectModal({
) : (
-
{t('export.npcsHint')}
-
- {npcs.map((n) => ( - - ))} -
- + {npcs.length > 0 ? ( + <> +
{t('export.npcsHint')}
+
+ {npcs.map((n) => ( + + ))} +
+ + + ) : null} + {playlistsState.playlists.length > 0 ? ( + <> +
{t('export.playlists')}
+
{t('export.playlistsHint')}
+
+ {playlistsState.playlists.map((pl) => ( + + ))} +
+ + ) : null}
)} @@ -289,7 +326,7 @@ export function ExportProjectModal({ disabled={!canContinueStorylines || saving} onClick={goNextFromStorylines} > - {npcs.length > 0 ? t('export.next') : t('export.saveAs')} + {npcs.length > 0 || playlistsState.playlists.length > 0 ? t('export.next') : t('export.saveAs')} ) : ( + + +
+
+ {formatTime(current)} + { + 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; + }} + /> + {formatTime(duration)} +
+ + + ); +} diff --git a/app/renderer/shared/playlists/PlaylistsPanel.module.css b/app/renderer/shared/playlists/PlaylistsPanel.module.css new file mode 100644 index 0000000..99b29b9 --- /dev/null +++ b/app/renderer/shared/playlists/PlaylistsPanel.module.css @@ -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); +} diff --git a/app/renderer/shared/playlists/PlaylistsPanel.tsx b/app/renderer/shared/playlists/PlaylistsPanel.tsx new file mode 100644 index 0000000..125dbc2 --- /dev/null +++ b/app/renderer/shared/playlists/PlaylistsPanel.tsx @@ -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(null); + const [editingName, setEditingName] = useState(''); + const [editingAudioId, setEditingAudioId] = useState(null); + const [editingAudioTitle, setEditingAudioTitle] = useState(''); + const [pendingDelete, setPendingDelete] = useState(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(null); + const [audioEl, setAudioEl] = useState(null); + const [playing, setPlaying] = useState(false); + const [gain, setGain] = useState(1); + const [queuePlaylistId, setQueuePlaylistId] = useState(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 ( +
+
+
+ + {!readOnly ? ( + + ) : null} +
+
+ {filtered.playlists.length === 0 ? ( +
{labels.emptyPlaylists}
+ ) : ( + filtered.playlists.map((pl) => ( + + )) + )} +
+
+ +
+ {!readOnly && selected && audioFileDrop.dragOver ? ( +
{labels.dropAudioHint}
+ ) : null} + {selected ? ( + <> +
+ {readOnly ? ( +
{selected.name || labels.untitled}
+ ) : ( + void saveRename()} /> + )} +
+ + {!readOnly ? ( + <> + + + + + ) : null} +
+
+
+ {selected.tracks.length === 0 ? ( +
+ {labels.emptyTracks} + {!readOnly ?
{labels.dropAudioHint}
: null} +
+ ) : ( + 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 ( +
{ + 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)} + > + + {readOnly ? ( + {title} + ) : editingAudioId === tr.audioId ? ( + { + void api.invoke(ipcChannels.playlists.renameAudio, { + audioId: tr.audioId, + title: editingAudioTitle, + }); + setEditingAudioId(null); + }} + /> + ) : ( + + )} + {!readOnly ? ( + + ) : null} +
+ ); + }) + )} +
+ + ) : ( +
{labels.emptyPlaylists}
+ )} + + { + 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} + /> +
+ {!readOnly && pendingDelete + ? createPortal( + <> + +
+
+ {t('playlists.deleteConfirm', { name: pendingDelete.name })} +
+
+ + +
+ + , + document.body, + ) + : null} + + ); +} + +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'), + }; +} diff --git a/app/renderer/shared/playlists/useAppPlaylists.ts b/app/renderer/shared/playlists/useAppPlaylists.ts new file mode 100644 index 0000000..fb0bd2f --- /dev/null +++ b/app/renderer/shared/playlists/useAppPlaylists.ts @@ -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(EMPTY); + + useEffect(() => { + void api.invoke(ipcChannels.playlists.getState, {}).then(setState); + return api.on(ipcChannels.playlists.stateChanged, (next) => setState(next)); + }, [api]); + + return state; +} diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts index cb1f809..5a4c678 100644 --- a/app/renderer/shared/ui/secondaryWindows.stability.test.ts +++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts @@ -12,6 +12,7 @@ const SECONDARY_WINDOW_MAINS = [ 'npcs/npcsEditorMain.tsx', 'npcs/npcsMain.tsx', 'materials/main.tsx', + 'playlists/main.tsx', 'sceneEditor/main.tsx', 'sceneDescription/main.tsx', 'control/main.tsx', @@ -125,6 +126,20 @@ void test('EditorApp: split Run button and launch-with-players modal', () => { assert.ok(editor.includes('top.runWithPlayers')); }); +void test('EditorApp: Playlists header and modal', () => { + const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8'); + assert.ok(src.includes('data-testid="playlists-header-btn"')); + assert.ok(src.includes('PlaylistsManagerModal')); + assert.ok(src.includes('setPlaylistsManagerOpen(true)')); + assert.ok(src.includes('setPlaylistsManagerOpen(false)')); +}); + +void test('ControlApp: Playlists tool opens window', () => { + const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8'); + assert.ok(src.includes('data-testid="control-playlists-tool"')); + assert.ok(src.includes('windows.openPlaylists')); +}); + void test('PlayerTokenView: name plate shows full text without ellipsis', () => { const css = fs.readFileSync( path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'), diff --git a/app/shared/appBranding.ts b/app/shared/appBranding.ts index 51ace2e..038e26a 100644 --- a/app/shared/appBranding.ts +++ b/app/shared/appBranding.ts @@ -22,6 +22,7 @@ export type AppWindowKind = | 'boot' | 'sceneDescription' | 'materials' + | 'playlists' | 'npcsEditor' | 'sceneEditor' | 'tokenPathEditor' @@ -34,6 +35,7 @@ const WINDOW_SUFFIX: Record = { boot: { ru: 'Загрузка', en: 'Loading' }, sceneDescription: { ru: 'Описание сцены', en: 'Scene description' }, materials: { ru: 'Материалы', en: 'Materials' }, + playlists: { ru: 'Плейлисты', en: 'Playlists' }, npcsEditor: { ru: 'НПС', en: 'NPCs' }, sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' }, tokenPathEditor: { ru: 'Движение токена', en: 'Token path' }, diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 2a9333c..a39114e 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -20,6 +20,12 @@ import type { AppToken, AppPlayer, AppPlayerTeam, + AppPlaylist, + PlaylistAudio, + PlaylistAudioId, + PlaylistId, + PlaylistTrackId, + PlaylistsState, PlayerId, PlayerTeamId, PlayerImageOffset, @@ -149,6 +155,8 @@ export const ipcChannels = { sceneDescriptionContent: 'windows.sceneDescriptionContent', openMaterials: 'windows.openMaterials', closeMaterials: 'windows.closeMaterials', + openPlaylists: 'windows.openPlaylists', + closePlaylists: 'windows.closePlaylists', openNpcsEditor: 'windows.openNpcsEditor', closeNpcsEditor: 'windows.closeNpcsEditor', openNpcs: 'windows.openNpcs', @@ -243,6 +251,21 @@ export const ipcChannels = { upsertProgress: 'players.upsertProgress', stateChanged: 'players.stateChanged', }, + playlists: { + getState: 'playlists.getState', + create: 'playlists.create', + rename: 'playlists.rename', + duplicate: 'playlists.duplicate', + delete: 'playlists.delete', + setOrder: 'playlists.setOrder', + setTracksOrder: 'playlists.setTracksOrder', + addAudioFiles: 'playlists.addAudioFiles', + pickAudioFiles: 'playlists.pickAudioFiles', + removeTrack: 'playlists.removeTrack', + renameAudio: 'playlists.renameAudio', + audioUrl: 'playlists.audioUrl', + stateChanged: 'playlists.stateChanged', + }, sceneNpcTokensSession: { getState: 'sceneNpcTokensSession.getState', dispatch: 'sceneNpcTokensSession.dispatch', @@ -342,6 +365,7 @@ export type IpcEventMap = { [ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean }; [ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] }; [ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent; + [ipcChannels.playlists.stateChanged]: PlaylistsState; [ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState }; [ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState }; [ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState }; @@ -667,6 +691,7 @@ export type IpcInvokeMap = { storylineSelections: StorylineSelection[]; npcIds: string[]; labels: StorylineLabels; + playlistIds?: string[]; }; res: { canceled: true } | { canceled: false }; }; @@ -726,6 +751,14 @@ export type IpcInvokeMap = { req: Record; res: { ok: true }; }; + [ipcChannels.windows.openPlaylists]: { + req: Record; + res: { ok: true }; + }; + [ipcChannels.windows.closePlaylists]: { + req: Record; + res: { ok: true }; + }; [ipcChannels.windows.openNpcsEditor]: { req: Record; res: { ok: true }; @@ -914,6 +947,54 @@ export type IpcInvokeMap = { req: { id: PlayerId }; res: { url: string | null }; }; + [ipcChannels.playlists.getState]: { + req: Record; + 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; + res: { filePaths: string[] }; + }; + [ipcChannels.playlists.removeTrack]: { + req: { playlistId: PlaylistId; trackId: PlaylistTrackId }; + res: { playlist: AppPlaylist; state: PlaylistsState }; + }; + [ipcChannels.playlists.renameAudio]: { + req: { audioId: PlaylistAudioId; title: string }; + res: { audio: PlaylistAudio; state: PlaylistsState }; + }; + [ipcChannels.playlists.audioUrl]: { + req: { id: PlaylistAudioId }; + res: { url: string | null }; + }; [ipcChannels.sceneNpcTokensSession.getState]: { req: Record; res: { state: SceneNpcTokensSessionState }; @@ -996,6 +1077,7 @@ export type LegacyIpcEventMap = { [ipcChannels.tokenGridSnap.stateChanged]: { enabled: boolean }; [ipcChannels.players.stateChanged]: { players: AppPlayer[]; teams: AppPlayerTeam[] }; [ipcChannels.players.upsertProgress]: PlayersUpsertProgressEvent; + [ipcChannels.playlists.stateChanged]: PlaylistsState; [ipcChannels.sceneNpcTokensSession.stateChanged]: { state: SceneNpcTokensSessionState }; [ipcChannels.npcMapSpawn.stateChanged]: { state: NpcMapSpawnDragState }; [ipcChannels.scenePlayerTokensSession.stateChanged]: { state: ScenePlayerTokensSessionState }; diff --git a/app/shared/types/appPlaylists.ts b/app/shared/types/appPlaylists.ts new file mode 100644 index 0000000..59edbe8 --- /dev/null +++ b/app/shared/types/appPlaylists.ts @@ -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 } { + 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(); + 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 }; +} diff --git a/app/shared/types/ids.ts b/app/shared/types/ids.ts index 5b67e01..102a286 100644 --- a/app/shared/types/ids.ts +++ b/app/shared/types/ids.ts @@ -13,6 +13,9 @@ export type SceneTokenId = Brand; export type PlayerId = Brand; export type PlayerTeamId = Brand; export type SceneNpcTokenId = Brand; +export type PlaylistId = Brand; +export type PlaylistAudioId = Brand; +export type PlaylistTrackId = Brand; export function asProjectId(value: string): ProjectId { return value as ProjectId; @@ -65,3 +68,15 @@ export function asPlayerTeamId(value: string): PlayerTeamId { export function asSceneNpcTokenId(value: string): SceneNpcTokenId { return value as SceneNpcTokenId; } + +export function asPlaylistId(value: string): PlaylistId { + return value as PlaylistId; +} + +export function asPlaylistAudioId(value: string): PlaylistAudioId { + return value as PlaylistAudioId; +} + +export function asPlaylistTrackId(value: string): PlaylistTrackId { + return value as PlaylistTrackId; +} diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 078464e..9cac237 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -1,4 +1,5 @@ export * from './appPlayers'; +export * from './appPlaylists'; export * from './appTokens'; export * from './domain'; export * from './effects'; diff --git a/docs/vk-covers/vk-cover-01-traps.png b/docs/vk-covers/vk-cover-01-traps.png new file mode 100644 index 0000000..0231f4b Binary files /dev/null and b/docs/vk-covers/vk-cover-01-traps.png differ diff --git a/docs/vk-covers/vk-cover-02-grid.png b/docs/vk-covers/vk-cover-02-grid.png new file mode 100644 index 0000000..ed83bf3 Binary files /dev/null and b/docs/vk-covers/vk-cover-02-grid.png differ diff --git a/docs/vk-covers/vk-cover-03-tokens.png b/docs/vk-covers/vk-cover-03-tokens.png new file mode 100644 index 0000000..3ae2e8f Binary files /dev/null and b/docs/vk-covers/vk-cover-03-tokens.png differ diff --git a/docs/vk-covers/vk-cover-04-closing-brush.png b/docs/vk-covers/vk-cover-04-closing-brush.png new file mode 100644 index 0000000..1a0da62 Binary files /dev/null and b/docs/vk-covers/vk-cover-04-closing-brush.png differ diff --git a/docs/vk-covers/vk-cover-05-materials.png b/docs/vk-covers/vk-cover-05-materials.png new file mode 100644 index 0000000..3221890 Binary files /dev/null and b/docs/vk-covers/vk-cover-05-materials.png differ diff --git a/docs/vk-covers/vk-cover-06-players.png b/docs/vk-covers/vk-cover-06-players.png new file mode 100644 index 0000000..d8e6ccc Binary files /dev/null and b/docs/vk-covers/vk-cover-06-players.png differ diff --git a/docs/vk-covers/vk-cover-07-video-rotate.png b/docs/vk-covers/vk-cover-07-video-rotate.png new file mode 100644 index 0000000..42098c8 Binary files /dev/null and b/docs/vk-covers/vk-cover-07-video-rotate.png differ diff --git a/docs/vk-covers/vk-cover-08-grid-snap.png b/docs/vk-covers/vk-cover-08-grid-snap.png new file mode 100644 index 0000000..72b1f8a Binary files /dev/null and b/docs/vk-covers/vk-cover-08-grid-snap.png differ diff --git a/docs/vk-covers/vk-cover-1.0.28-live-table.png b/docs/vk-covers/vk-cover-1.0.28-live-table.png new file mode 100644 index 0000000..7cbb52e Binary files /dev/null and b/docs/vk-covers/vk-cover-1.0.28-live-table.png differ diff --git a/docs/vk-covers/vk-cover-1.0.29-live-table.png b/docs/vk-covers/vk-cover-1.0.29-live-table.png new file mode 100644 index 0000000..1f3a3e6 Binary files /dev/null and b/docs/vk-covers/vk-cover-1.0.29-live-table.png differ diff --git a/docs/vk-covers/vk-cover-release-1-0-28.png b/docs/vk-covers/vk-cover-release-1-0-28.png new file mode 100644 index 0000000..455f500 Binary files /dev/null and b/docs/vk-covers/vk-cover-release-1-0-28.png differ diff --git a/docs/vk-news-1.0.27.md b/docs/vk-news-1.0.27.md new file mode 100644 index 0000000..066e65f --- /dev/null +++ b/docs/vk-news-1.0.27.md @@ -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 +#НРИплеер #пульт #сетка diff --git a/docs/vk-news-1.0.28-release.md b/docs/vk-news-1.0.28-release.md new file mode 100644 index 0000000..5fb1ea2 --- /dev/null +++ b/docs/vk-news-1.0.28-release.md @@ -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 #релиз diff --git a/docs/vk-news-after-1.0.28.md b/docs/vk-news-after-1.0.28.md new file mode 100644 index 0000000..d1b6747 --- /dev/null +++ b/docs/vk-news-after-1.0.28.md @@ -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 #мастер #настолки #игроки #НПС diff --git a/package.json b/package.json index 72fd3fa..42b3218 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "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:write": "prettier . --write", "postinstall": "patch-package", diff --git a/scripts/ttrpg-release/publish.ps1 b/scripts/ttrpg-release/publish.ps1 index 3e32286..809aa0e 100644 --- a/scripts/ttrpg-release/publish.ps1 +++ b/scripts/ttrpg-release/publish.ps1 @@ -128,7 +128,7 @@ function Add-Upload { if ($map.ContainsKey($remoteName)) { $prev = $map[$remoteName] 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 diff --git a/vite.config.ts b/vite.config.ts index 82f27b6..d649a3f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => { control: path.resolve(__dirname, 'app/renderer/control.html'), sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.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'), tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'), npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),