DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import crypto from 'node:crypto';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
function nowMs(): number {
return Date.now();
}
function defaultTool(): EffectToolState {
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
}
export class EffectsStore {
private state: EffectsState = {
revision: 1,
serverNowMs: nowMs(),
tool: defaultTool(),
instances: [],
};
getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
return { ...this.state, serverNowMs: nowMs() };
}
clear(): EffectsState {
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: nowMs(),
instances: [],
};
return this.state;
}
dispatch(event: EffectsEvent): EffectsState {
const s = this.state;
const next: EffectsState = applyEvent(s, event);
this.state = next;
return next;
}
/** Удаляет истёкшие (по lifetime) эффекты, чтобы state не разрастался бесконечно. */
pruneExpired(): boolean {
const now = nowMs();
const before = this.state.instances.length;
const kept = this.state.instances.filter((i) => {
if (i.type === 'lightning') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'scorch') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'fog') {
if (i.lifetimeMs === null) return true;
return now - i.createdAtMs < i.lifetimeMs;
}
return true;
});
if (kept.length === before) return false;
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: now,
instances: kept,
};
return true;
}
makeId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
}
}
function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
...patch,
revision: state.revision + 1,
serverNowMs: nowMs(),
});
switch (event.kind) {
case 'tool.set':
return bump({ ...state, tool: event.tool });
case 'instances.clear':
return bump({ ...state, instances: [] });
case 'instance.add':
return bump({ ...state, instances: [...state.instances, event.instance] });
case 'instance.remove':
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
default: {
// Exhaustiveness
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return state;
}
}
}
+344
View File
@@ -0,0 +1,344 @@
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
import { EffectsStore } from './effects/effectsStore';
import { installIpcRouter, registerHandler } from './ipc/router';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
closeMultiWindow,
createWindows,
focusEditorWindow,
markAppQuitting,
openMultiWindow,
togglePresentationFullscreen,
} from './windows/createWindows';
if (process.platform === 'win32') {
app.setAppUserModelId('com.dndplayer.app');
}
// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData,
// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке).
protocol.registerSchemesAsPrivileged([
{
scheme: 'dnd',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
},
},
]);
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
focusEditorWindow();
});
}
const projectStore = new ZipProjectStore();
const effectsStore = new EffectsStore();
const videoStore = new VideoPlaybackStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.effects.stateChanged, { state });
}
}
function emitVideoState(): void {
const state = videoStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.video.stateChanged, { state });
}
}
// Периодически чистим истёкшие эффекты (в основном молнии).
setInterval(() => {
if (effectsStore.pruneExpired()) {
emitEffectsState();
}
}, 500);
// Пока видео "играет" — периодически рассылаем state (нужен для новых окон и коррекции).
setInterval(() => {
if (videoStore.getState().playing) {
emitVideoState();
}
}, 500);
function emitSessionState(): void {
const project = projectStore.getOpenProject();
const state: SessionState = {
project,
currentSceneId: project?.currentSceneId ?? null,
};
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.session.stateChanged, { state });
}
}
async function main() {
await app.whenReady();
Menu.setApplicationMenu(null);
registerDndAssetProtocol(projectStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
return { ok: true };
});
registerHandler(ipcChannels.app.getVersion, () => ({
version: getAppSemanticVersion(),
buildNumber: getOptionalBuildNumber(),
}));
registerHandler(ipcChannels.windows.openMultiWindow, () => {
openMultiWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
closeMultiWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
const isFullScreen = togglePresentationFullscreen();
return { ok: true, isFullScreen };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
return {
projects: projects.map((p) => ({
id: p.id,
name: p.name,
updatedAt: p.updatedAt,
fileName: p.fileName,
})),
};
});
registerHandler(ipcChannels.project.create, async ({ name }) => {
const project = await projectStore.createProject(name);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.get, () => {
return { project: projectStore.getOpenProject() };
});
registerHandler(ipcChannels.project.saveNow, async () => {
await projectStore.saveNow();
return { ok: true };
});
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
emitEffectsState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
});
registerHandler(ipcChannels.project.setCurrentGraphNode, async ({ graphNodeId }) => {
const open = projectStore.getOpenProject();
if (!open) throw new Error('No open project');
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
await projectStore.updateProject((p) => ({
...p,
currentGraphNodeId: graphNodeId,
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
emitEffectsState();
emitSessionState();
const p = projectStore.getOpenProject();
return {
currentGraphNodeId: p?.currentGraphNodeId ?? null,
currentSceneId: p?.currentSceneId ?? null,
};
});
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
const next = await projectStore.updateScene(sceneId, patch);
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.updateConnections, async ({ sceneId, connections }) => {
const next = await projectStore.updateConnections(sceneId, connections);
emitSessionState();
return { scene: next };
});
registerHandler(ipcChannels.project.importMedia, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Видео и аудио',
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
});
if (canceled || filePaths.length === 0) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project, imported: [] };
}
const result = await projectStore.importMediaFiles(sceneId, filePaths);
emitSessionState();
return result;
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: 'Изображения и видео',
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
},
],
});
if (canceled || !filePaths[0]) {
const project = projectStore.getOpenProject();
if (!project) throw new Error('No open project');
return { project };
}
const project = await projectStore.importScenePreviewMedia(sceneId, filePaths[0]);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.clearScenePreview, async ({ sceneId }) => {
const project = await projectStore.clearScenePreview(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateSceneGraphNodePosition, async ({ nodeId, x, y }) => {
const project = await projectStore.updateSceneGraphNodePosition(nodeId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.addSceneGraphNode, async ({ sceneId, x, y }) => {
const project = await projectStore.addSceneGraphNode(sceneId, x, y);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.removeSceneGraphNode, async ({ nodeId }) => {
const project = await projectStore.removeSceneGraphNode(nodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.addSceneGraphEdge, async ({ sourceGraphNodeId, targetGraphNodeId }) => {
const project = await projectStore.addSceneGraphEdge(sourceGraphNodeId, targetGraphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.removeSceneGraphEdge, async ({ edgeId }) => {
const project = await projectStore.removeSceneGraphEdge(edgeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setSceneGraphNodeStart, async ({ graphNodeId }) => {
const project = await projectStore.setSceneGraphNodeStart(graphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
const project = await projectStore.deleteScene(sceneId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => {
const project = await projectStore.renameOpenProject(name, fileBaseName);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importZip, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const project = await projectStore.importProjectFromExternalZip(filePaths[0]);
emitSessionState();
return { canceled: false as const, project };
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = entry.fileName.toLowerCase().endsWith('.dnd.zip')
? entry.fileName
: `${entry.fileName}.dnd.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
let dest = filePath;
const lower = dest.toLowerCase();
if (!lower.endsWith('.dnd.zip')) {
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`;
}
await projectStore.exportProjectZipToPath(projectId, dest);
return { canceled: false as const };
});
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId);
emitSessionState();
return { ok: true };
});
registerHandler(ipcChannels.project.assetFileUrl, ({ assetId }) => ({
url: projectStore.getAssetFileUrl(assetId),
}));
registerHandler(ipcChannels.effects.getState, () => {
return { state: effectsStore.getState() };
});
registerHandler(ipcChannels.effects.dispatch, ({ event }) => {
effectsStore.dispatch(event);
emitEffectsState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
registerHandler(ipcChannels.video.dispatch, ({ event }) => {
videoStore.dispatch(event);
emitVideoState();
return { ok: true };
});
installIpcRouter();
createWindows();
emitSessionState();
emitEffectsState();
emitVideoState();
app.on('activate', () => {
focusEditorWindow();
});
}
if (gotTheLock) {
void main();
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+19
View File
@@ -0,0 +1,19 @@
import { ipcMain } from 'electron';
import type { IpcInvokeMap } from '../../shared/ipc/contracts';
type Handler<K extends keyof IpcInvokeMap> = (
payload: IpcInvokeMap[K]['req'],
) => Promise<IpcInvokeMap[K]['res']> | IpcInvokeMap[K]['res'];
const handlers = new Map<string, (payload: unknown) => Promise<unknown>>();
export function registerHandler<K extends keyof IpcInvokeMap>(channel: K, handler: Handler<K>) {
handlers.set(channel as string, async (payload: unknown) => handler(payload as IpcInvokeMap[K]['req']));
}
export function installIpcRouter() {
for (const [channel, handler] of handlers.entries()) {
ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload));
}
}
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { PROJECT_SCHEMA_VERSION, type Project } from '../../shared/types';
import type { AssetId } from '../../shared/types/ids';
import { collectReferencedAssetIds, reconcileAssetFiles } from './assetPrune';
void test('collectReferencedAssetIds: превью, видео и аудио', () => {
const p = {
scenes: {
s1: {
previewAssetId: 'pr' as AssetId,
media: {
videos: ['v1' as AssetId],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
},
},
},
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'pr', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-'));
const relPath = 'assets/orphan.bin';
await fs.mkdir(path.join(tmp, 'assets'), { recursive: true });
await fs.writeFile(path.join(tmp, relPath), Buffer.from([1, 2, 3]));
const asset = {
id: 'orph' as AssetId,
type: 'audio' as const,
mime: 'audio/wav',
originalName: 'x.wav',
relPath,
sha256: 'a',
sizeBytes: 3,
createdAt: new Date().toISOString(),
};
const base = {
id: 'p1',
meta: {
name: 't',
fileBaseName: 't',
createdAt: '',
updatedAt: '',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: PROJECT_SCHEMA_VERSION,
},
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
} as unknown as Project;
const prev: Project = {
...base,
scenes: {},
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
assets: { orphan: asset } as Project['assets'],
};
const out = await reconcileAssetFiles(prev, next, tmp);
assert.ok(!('orphan' in out.assets));
await assert.rejects(() => fs.stat(path.join(tmp, relPath)));
});
void test('reconcileAssetFiles: удаляет файл при исключении id из assets', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-asset-prune-'));
const relPath = 'assets/gone.bin';
await fs.mkdir(path.join(tmp, 'assets'), { recursive: true });
await fs.writeFile(path.join(tmp, relPath), Buffer.from([9]));
const asset = {
id: 'gone' as AssetId,
type: 'audio' as const,
mime: 'audio/wav',
originalName: 'x.wav',
relPath,
sha256: 'b',
sizeBytes: 1,
createdAt: new Date().toISOString(),
};
const base = {
id: 'p1',
meta: {
name: 't',
fileBaseName: 't',
createdAt: '',
updatedAt: '',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes: {},
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
await assert.rejects(() => fs.stat(path.join(tmp, relPath)));
});
+51
View File
@@ -0,0 +1,51 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { MediaAsset, Project } from '../../shared/types';
import type { AssetId } from '../../shared/types/ids';
/** Все asset id, на которые есть ссылки из сцен (превью, видео, аудио). */
export function collectReferencedAssetIds(p: Project): Set<AssetId> {
const refs = new Set<AssetId>();
for (const sc of Object.values(p.scenes)) {
if (sc.previewAssetId) refs.add(sc.previewAssetId);
for (const vid of sc.media.videos) refs.add(vid);
for (const au of sc.media.audios) refs.add(au.assetId);
}
return refs;
}
/**
* Удаляет с диска файлы снятых материалов и записи в `assets`, на которые больше нет ссылок.
*/
export async function reconcileAssetFiles(prev: Project, next: Project, cacheDir: string): Promise<Project> {
const prevIds = new Set(Object.keys(prev.assets) as AssetId[]);
const nextIds = new Set(Object.keys(next.assets) as AssetId[]);
for (const id of prevIds) {
if (nextIds.has(id)) continue;
const a = prev.assets[id];
if (a) {
const abs = path.join(cacheDir, a.relPath);
await fs.unlink(abs).catch(() => undefined);
}
}
const refs = collectReferencedAssetIds(next);
const assets = next.assets;
const kept: Record<AssetId, MediaAsset> = {} as Record<AssetId, MediaAsset>;
let droppedOrphans = false;
for (const id of Object.keys(assets) as AssetId[]) {
const a = assets[id];
if (!a) continue;
if (refs.has(id)) {
kept[id] = a;
} else {
droppedOrphans = true;
const abs = path.join(cacheDir, a.relPath);
await fs.unlink(abs).catch(() => undefined);
}
}
return droppedOrphans ? { ...next, assets: kept } : next;
}
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path';
import { app } from 'electron';
export function getProjectsRootDir(): string {
return path.join(app.getPath('userData'), 'projects');
}
export function getProjectsCacheRootDir(): string {
return path.join(app.getPath('userData'), 'projects-cache');
}
/**
* Каталоги `…/projects` из других имён приложения в `%AppData%` (родитель `userData`).
* Если когда‑то меняли `app.setName`, проекты могли остаться в соседней папке — их подхватываем при старте.
*/
export function getLegacyProjectsRootDirs(): string[] {
const cur = getProjectsRootDir();
const parent = path.dirname(app.getPath('userData'));
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
const out: string[] = [];
for (const n of siblingNames) {
const p = path.join(parent, n, 'projects');
if (p !== cur) out.push(p);
}
return out;
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
import fs from 'node:fs/promises';
import { session } from 'electron';
import { asAssetId } from '../../shared/types/ids';
import type { ZipProjectStore } from '../project/zipStore';
/**
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
*/
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
if (url.hostname !== 'asset') {
return new Response(null, { status: 404 });
}
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
const info = projectStore.getAssetReadInfo(asAssetId(id));
if (!info) {
return new Response(null, { status: 404 });
}
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
const range = request.headers.get('range') ?? request.headers.get('Range');
if (range) {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
if (m) {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
return new Response(null, { status: 416 });
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
await fh.read(buf, 0, len, start);
return new Response(buf, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
'Content-Length': String(len),
'Cache-Control': 'public, max-age=300',
},
});
} finally {
await fh.close();
}
}
}
const buf = await fs.readFile(info.absPath);
return new Response(buf, {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Length': String(buf.length),
'Cache-Control': 'public, max-age=300',
},
});
} catch {
return new Response(null, { status: 404 });
}
});
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "../../dist/main",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"types": ["node", "electron"],
"lib": ["ES2022"]
},
"include": ["./**/*.ts"],
"exclude": ["../../dist", "../../node_modules"]
}
+24
View File
@@ -0,0 +1,24 @@
import { app } from 'electron';
/**
* Семантическая версия приложения: из упакованного приложения — `app.getVersion()` (= `version` из package.json),
* в dev при запуске через npm — обычно то же; иначе fallback на `npm_package_version` / `0.0.0`.
*/
export function getAppSemanticVersion(): string {
try {
const v = app.getVersion();
if (typeof v === 'string' && v.trim().length > 0) {
return v.trim();
}
} catch {
/* вне процесса Electron */
}
const fromEnv = process.env.npm_package_version?.trim();
return fromEnv !== undefined && fromEnv.length > 0 ? fromEnv : '0.0.0';
}
/** Необязательный номер сборки из CI (`DND_BUILD_NUMBER`). */
export function getOptionalBuildNumber(): string | null {
const b = process.env.DND_BUILD_NUMBER?.trim();
return b && b.length > 0 ? b : null;
}
+108
View File
@@ -0,0 +1,108 @@
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../shared/types';
function nowMs(): number {
return Date.now();
}
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
export class VideoPlaybackStore {
private state: VideoPlaybackState = {
revision: 1,
serverNowMs: nowMs(),
targetAssetId: null,
playing: false,
playbackRate: 1,
anchorServerMs: nowMs(),
anchorVideoTimeSec: 0,
};
getState(): VideoPlaybackState {
return { ...this.state, serverNowMs: nowMs() };
}
dispatch(event: VideoPlaybackEvent): VideoPlaybackState {
const s = this.getState();
const curTime = computeTimeSec(s, s.serverNowMs);
const bump = (patch: Omit<VideoPlaybackState, 'revision' | 'serverNowMs'>): VideoPlaybackState => ({
...patch,
revision: s.revision + 1,
serverNowMs: nowMs(),
});
switch (event.kind) {
case 'target.set': {
const nextTarget = event.assetId ?? null;
const next: Omit<VideoPlaybackState, 'revision' | 'serverNowMs'> = {
...s,
targetAssetId: nextTarget,
playing: Boolean(event.autostart) && nextTarget !== null,
playbackRate: s.playbackRate,
anchorServerMs: s.serverNowMs,
anchorVideoTimeSec: 0,
};
this.state = bump(next);
return this.state;
}
case 'play': {
this.state = bump({
...s,
playing: true,
anchorServerMs: s.serverNowMs,
anchorVideoTimeSec: curTime,
});
return this.state;
}
case 'pause': {
this.state = bump({
...s,
playing: false,
anchorServerMs: s.serverNowMs,
anchorVideoTimeSec: curTime,
});
return this.state;
}
case 'stop': {
this.state = bump({
...s,
playing: false,
anchorServerMs: s.serverNowMs,
anchorVideoTimeSec: 0,
});
return this.state;
}
case 'seek': {
const t = clamp(event.timeSec, 0, 1_000_000);
this.state = bump({
...s,
anchorServerMs: s.serverNowMs,
anchorVideoTimeSec: t,
});
return this.state;
}
case 'rate.set': {
const rate = clamp(event.rate, 0.25, 3);
this.state = bump({
...s,
playbackRate: rate,
anchorServerMs: s.serverNowMs,
anchorVideoTimeSec: curTime,
});
return this.state;
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.state;
}
}
}
}
export function computeTimeSec(state: VideoPlaybackState, atServerNowMs: number): number {
if (!state.playing) return state.anchorVideoTimeSec;
const dt = Math.max(0, atServerNowMs - state.anchorServerMs);
return state.anchorVideoTimeSec + (dt / 1000) * state.playbackRate;
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function readCreateWindows(): string {
return fs.readFileSync(path.join(here, 'createWindows.ts'), 'utf8');
}
void test('createWindows: закрытие редактора завершает приложение', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'editor'/);
assert.match(src, /win\.on\(\s*['"]close['"]/);
assert.ok(src.includes('appQuitting'));
assert.ok(src.includes('e.preventDefault()'));
assert.ok(src.includes('quitAppFromEditorClose') || src.includes('app.quit()'));
assert.ok(src.includes('markAppQuitting'));
});
void test('createWindows: иконка окна (PNG приоритетно, затем SVG)', () => {
const src = readCreateWindows();
assert.ok(src.includes('resolveWindowIconPath'));
assert.ok(src.includes('app-window-icon.png'));
assert.ok(src.includes('app-logo.svg'));
});
+159
View File
@@ -0,0 +1,159 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { app, BrowserWindow, nativeImage, screen } from 'electron';
type WindowKind = 'editor' | 'presentation' | 'control';
const windows = new Map<WindowKind, BrowserWindow>();
let appQuitting = false;
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
}
function quitAppFromEditorClose(): void {
markAppQuitting();
app.quit();
}
function isDev() {
return process.env.NODE_ENV === 'development' || process.env.VITE_DEV_SERVER_URL !== undefined;
}
function getRendererUrl(kind: WindowKind): string {
const dev = process.env.VITE_DEV_SERVER_URL;
if (dev) {
const page =
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
return new URL(page, dev).toString();
}
const filePath = path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
return pathToFileURL(filePath).toString();
}
function getPreloadPath(): string {
return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs');
}
/**
* Иконка окна. На Windows `nativeImage` из SVG часто пустой — сначала ищем PNG
* (`app-window-icon.png`), затем SVG из public / dist.
*/
function resolveWindowIconPath(): string | undefined {
const root = app.getAppPath();
const candidates = [
path.join(root, 'dist', 'renderer', 'app-window-icon.png'),
path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'),
path.join(root, 'dist', 'renderer', 'app-logo.svg'),
path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'),
];
for (const p of candidates) {
try {
if (fs.existsSync(p)) return p;
} catch {
/* ignore */
}
}
return undefined;
}
function resolveWindowIcon(): Electron.NativeImage | undefined {
const p = resolveWindowIconPath();
if (!p) return undefined;
try {
const img = nativeImage.createFromPath(p);
if (!img.isEmpty()) return img;
} catch {
/* ignore */
}
return undefined;
}
function createWindow(kind: WindowKind): BrowserWindow {
const icon = resolveWindowIcon();
const win = new BrowserWindow({
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
height: 800,
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
webPreferences: {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
devTools: isDev(),
preload: getPreloadPath(),
autoplayPolicy: 'no-user-gesture-required',
},
});
win.webContents.on('preload-error', (_event, preloadPath, error) => {
console.error(`[preload-error] ${preloadPath}:`, error);
});
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
});
win.once('ready-to-show', () => win.show());
void win.loadURL(getRendererUrl(kind));
if (kind === 'editor') {
win.on('close', (e) => {
if (appQuitting) return;
e.preventDefault();
quitAppFromEditorClose();
});
}
win.on('closed', () => windows.delete(kind));
windows.set(kind, win);
return win;
}
export function createWindows() {
if (!windows.has('editor')) {
createWindow('editor');
}
}
export function focusEditorWindow(): void {
const win = windows.get('editor');
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
} else {
createWindows();
}
}
export function openMultiWindow() {
if (!windows.has('presentation')) {
const display = screen.getPrimaryDisplay();
const { x, y, width, height } = display.bounds;
const win = createWindow('presentation');
win.setBounds({ x, y, width, height });
win.setMenuBarVisibility(false);
win.maximize();
}
if (!windows.has('control')) {
createWindow('control');
}
}
export function closeMultiWindow(): void {
const pres = windows.get('presentation');
const ctrl = windows.get('control');
if (pres) pres.close();
if (ctrl) ctrl.close();
}
export function togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;
const next = !pres.isFullScreen();
pres.setFullScreen(next);
return pres.isFullScreen();
}