diff --git a/app/main/index.ts b/app/main/index.ts index 858ab65..56e2acb 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -34,6 +34,7 @@ import { LicenseService } from './license/licenseService'; import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences'; import { MaterialsOverlayStore } from './materials/materialsOverlayStore'; import { NpcsOverlayStore } from './npcs/npcsOverlayStore'; +import { TimerSessionStore } from './sessionTimer/timerSessionStore'; import { ZipProjectStore } from './project/zipStore'; import { SceneViewStore } from './sceneView/sceneViewStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; @@ -67,6 +68,7 @@ import { markAppQuitting, openMaterialsWindow, openPlaylistsWindow, + openTimerWindow, openMultiWindow, openNpcsEditorWindow, openSceneEditorWindow, @@ -75,6 +77,7 @@ import { openSceneDescriptionWindow, closeMaterialsWindow, closePlaylistsWindow, + closeTimerWindow, closeNpcsEditorWindow, closeSceneEditorWindow, closeTokenPathEditorWindow, @@ -178,6 +181,7 @@ const sceneViewStore = new SceneViewStore(); const videoStore = new VideoPlaybackStore(); const materialsOverlayStore = new MaterialsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore(); +const timerSessionStore = new TimerSessionStore(); const sceneTokensSessionStore = new SceneTokensSessionStore(); const tokenPathSessionStore = new TokenPathSessionStore(); const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore(); @@ -272,6 +276,13 @@ function syncNpcsOverlayWithProject(project: Project | null): void { npcsOverlayStore.ensureNpcStillExists(ids); } +function emitSessionTimerState(): void { + const state = timerSessionStore.getState(); + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.sessionTimer.stateChanged, { state }); + } +} + function emitSceneDarknessState(): void { const state = sceneDarknessStore.getState(); for (const win of BrowserWindow.getAllWindows()) { @@ -561,6 +572,7 @@ async function main() { clearNpcMapSpawnDrag(); scenePlayerTokensSessionStore.reset(); tokenGridSnapSessionStore.reset(); + timerSessionStore.clear(); if (USERS_BRANCH_FEATURES_ENABLED) { const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : []; if (playerIds.length > 0) { @@ -586,11 +598,14 @@ async function main() { emitScenePlayerTokensSessionState(); emitTokenGridSnapState(); emitEffectsState(); + emitSessionTimerState(); return { ok: true }; }); registerHandler(ipcChannels.windows.closeMultiWindow, () => { closeMultiWindow(); + timerSessionStore.clear(); tokenGridSnapSessionStore.reset(); + emitSessionTimerState(); emitTokenGridSnapState(); return { ok: true }; }); @@ -646,6 +661,14 @@ async function main() { closePlaylistsWindow(); return { ok: true }; }); + registerHandler(ipcChannels.windows.openTimer, () => { + openTimerWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.closeTimer, () => { + closeTimerWindow(); + return { ok: true }; + }); registerHandler(ipcChannels.windows.openNpcsEditor, () => { openNpcsEditorWindow(); return { ok: true }; @@ -701,6 +724,15 @@ async function main() { emitNpcsOverlayState(); return { ok: true }; }); + registerHandler(ipcChannels.sessionTimer.getState, () => { + return { state: timerSessionStore.getState() }; + }); + registerHandler(ipcChannels.sessionTimer.dispatch, ({ event }) => { + timerSessionStore.dispatch(event); + timerSessionStore.settleIfExpired(); + emitSessionTimerState(); + return { state: timerSessionStore.getState() }; + }); registerHandler(ipcChannels.project.list, async () => { const projects = await projectStore.listProjects(); @@ -742,6 +774,7 @@ async function main() { effectsStore.clear(); materialsOverlayStore.clear(); npcsOverlayStore.clear(); + timerSessionStore.clear(); sceneDarknessStore.resetSession(); sceneMarkerStore.resetSession(); sceneTrapsStore.resetSession(); @@ -754,6 +787,7 @@ async function main() { emitEffectsState(); emitMaterialsOverlayState(); emitNpcsOverlayState(); + emitSessionTimerState(); emitSceneDarknessState(); emitSceneMarkerState(); emitSceneTrapsState(); diff --git a/app/main/sessionTimer/timerSessionStore.test.ts b/app/main/sessionTimer/timerSessionStore.test.ts new file mode 100644 index 0000000..ead977c --- /dev/null +++ b/app/main/sessionTimer/timerSessionStore.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { TimerSessionStore } from './timerSessionStore'; +import { + clampSessionTimerLayout, + formatSessionTimerMs, + getSessionTimerRemainingMs, + parseSessionTimerDurationInput, +} from '../../shared/types/sessionTimer'; + +void test('getSessionTimerRemainingMs: paused uses remainingMs', () => { + const store = new TimerSessionStore(); + const s = store.getState(); + assert.equal(getSessionTimerRemainingMs(s, 1_000_000), s.remainingMs); +}); + +void test('start pause start preserves remaining', () => { + const store = new TimerSessionStore(); + const t0 = 1_000_000; + store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0); + store.dispatch({ kind: 'start' }, t0); + store.dispatch({ kind: 'pause' }, t0 + 10_000); + const afterPause = store.getState(); + assert.equal(afterPause.running, false); + assert.equal(afterPause.remainingMs, 50_000); + store.dispatch({ kind: 'start' }, t0 + 20_000); + assert.equal(getSessionTimerRemainingMs(store.getState(), t0 + 25_000), 45_000); +}); + +void test('reset restores duration', () => { + const store = new TimerSessionStore(); + store.dispatch({ kind: 'setDuration', durationMs: 90_000 }); + store.dispatch({ kind: 'start' }, 0); + store.dispatch({ kind: 'pause' }, 30_000); + store.dispatch({ kind: 'reset' }); + assert.equal(store.getState().remainingMs, 90_000); + assert.equal(store.getState().running, false); +}); + +void test('setVisibleToPlayers and layout.set', () => { + const store = new TimerSessionStore(); + store.dispatch({ kind: 'setVisibleToPlayers', visible: true }); + assert.equal(store.getState().visibleToPlayers, true); + store.dispatch({ + kind: 'layout.set', + layout: { cx: 0.2, cy: 0.3, scale: 2, rotationDeg: 45 }, + }); + assert.equal(store.getState().layout.cx, 0.2); + assert.equal(store.getState().layout.scale, 2); + assert.equal(store.getState().layout.rotationDeg, 45); +}); + +void test('settleIfExpired stops at zero', () => { + const store = new TimerSessionStore(); + store.dispatch({ kind: 'setDuration', durationMs: 5_000 }, 0); + store.dispatch({ kind: 'start' }, 0); + store.settleIfExpired(10_000); + assert.equal(store.getState().running, false); + assert.equal(store.getState().remainingMs, 0); +}); + +void test('clampSessionTimerLayout / format / parse', () => { + const clamped = clampSessionTimerLayout({ cx: 9, cy: -9, scale: 100, rotationDeg: 370 }); + assert.ok(clamped.cx <= 1.2); + assert.ok(clamped.cy >= -0.2); + assert.ok(clamped.scale <= 8); + assert.equal(clamped.rotationDeg, 10); + assert.equal(formatSessionTimerMs(65_000), '01:05'); + assert.equal(formatSessionTimerMs(3_661_000), '1:01:01'); + assert.equal(parseSessionTimerDurationInput('5:00'), 300_000); + assert.equal(parseSessionTimerDurationInput('1:02:03'), 3723_000); + assert.equal(parseSessionTimerDurationInput('bad'), null); +}); + +void test('clear restores defaults', () => { + const store = new TimerSessionStore(); + store.dispatch({ kind: 'setVisibleToPlayers', visible: true }); + store.dispatch({ kind: 'start' }, 0); + store.clear(); + assert.equal(store.getState().visibleToPlayers, false); + assert.equal(store.getState().running, false); +}); diff --git a/app/main/sessionTimer/timerSessionStore.ts b/app/main/sessionTimer/timerSessionStore.ts new file mode 100644 index 0000000..9d3fae6 --- /dev/null +++ b/app/main/sessionTimer/timerSessionStore.ts @@ -0,0 +1,131 @@ +import { + clampSessionTimerDurationMs, + clampSessionTimerLayout, + createInitialSessionTimerState, + getSessionTimerRemainingMs, + type SessionTimerEvent, + type SessionTimerState, +} from '../../shared/types/sessionTimer'; + +export class TimerSessionStore { + private state: SessionTimerState = createInitialSessionTimerState(); + + getState(): SessionTimerState { + return this.state; + } + + clear(): SessionTimerState { + this.state = createInitialSessionTimerState(); + return this.state; + } + + dispatch(event: SessionTimerEvent, now = Date.now()): SessionTimerState { + switch (event.kind) { + case 'setDuration': { + const durationMs = clampSessionTimerDurationMs(event.durationMs); + if (this.state.running) { + const remaining = getSessionTimerRemainingMs(this.state, now); + this.state = { + ...this.state, + revision: this.state.revision + 1, + durationMs, + remainingMs: Math.min(remaining, durationMs), + endsAt: now + Math.min(remaining, durationMs), + }; + } else { + this.state = { + ...this.state, + revision: this.state.revision + 1, + durationMs, + remainingMs: durationMs, + endsAt: null, + running: false, + }; + } + return this.state; + } + case 'start': { + const remaining = getSessionTimerRemainingMs(this.state, now); + if (remaining <= 0) { + // Restart from full duration if already at zero. + const durationMs = this.state.durationMs; + this.state = { + ...this.state, + revision: this.state.revision + 1, + remainingMs: durationMs, + running: true, + endsAt: now + durationMs, + }; + return this.state; + } + this.state = { + ...this.state, + revision: this.state.revision + 1, + running: true, + endsAt: now + remaining, + remainingMs: remaining, + }; + return this.state; + } + case 'pause': { + if (!this.state.running) return this.state; + const remaining = getSessionTimerRemainingMs(this.state, now); + this.state = { + ...this.state, + revision: this.state.revision + 1, + running: false, + endsAt: null, + remainingMs: remaining, + }; + return this.state; + } + case 'reset': { + this.state = { + ...this.state, + revision: this.state.revision + 1, + running: false, + endsAt: null, + remainingMs: this.state.durationMs, + }; + return this.state; + } + case 'setVisibleToPlayers': { + if (this.state.visibleToPlayers === event.visible) return this.state; + this.state = { + ...this.state, + revision: this.state.revision + 1, + visibleToPlayers: event.visible, + }; + return this.state; + } + case 'layout.set': { + const layout = clampSessionTimerLayout(event.layout); + this.state = { + ...this.state, + revision: this.state.revision + 1, + layout, + }; + return this.state; + } + default: { + const _exhaustive: never = event; + return _exhaustive; + } + } + } + + /** Когда running и время вышло — зафиксировать 00:00 (вызывается из UI/main по желанию). */ + settleIfExpired(now = Date.now()): SessionTimerState { + if (!this.state.running) return this.state; + const remaining = getSessionTimerRemainingMs(this.state, now); + if (remaining > 0) return this.state; + this.state = { + ...this.state, + revision: this.state.revision + 1, + running: false, + endsAt: null, + remainingMs: 0, + }; + return this.state; + } +} diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index 0f2b7ea..8078213 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -52,6 +52,14 @@ void test('createWindows: окно материалов закрывается assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/); }); +void test('createWindows: окно таймера закрывается с multi-window', () => { + const src = readCreateWindows(); + assert.ok(src.includes('openTimerWindow')); + assert.ok(src.includes('closeTimerWindow')); + assert.ok(src.includes("createWindow('timer'")); + assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/); +}); + void test('createWindows: окно НПС закрывается с multi-window', () => { const src = readCreateWindows(); assert.ok(src.includes('openNpcsWindow')); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 820ca95..544974a 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -21,6 +21,7 @@ export type WindowKind = | 'sceneDescription' | 'materials' | 'playlists' + | 'timer' | 'npcsEditor' | 'sceneEditor' | 'tokenPathEditor' @@ -59,6 +60,10 @@ const MATERIALS_WINDOW_HEIGHT = 720; const PLAYLISTS_WINDOW_WIDTH = 720; const PLAYLISTS_WINDOW_HEIGHT = 800; +/** Компактное окно таймера сессии. */ +const TIMER_WINDOW_WIDTH = 360; +const TIMER_WINDOW_HEIGHT = 420; + /** Редактор НПС — как основной редактор, шире инспектор. */ const NPCS_EDITOR_WINDOW_WIDTH = 1400; const NPCS_EDITOR_WINDOW_HEIGHT = 860; @@ -169,6 +174,7 @@ function closePlaySessionAuxiliaryWindows(): void { closeSceneDescriptionWindow(); closeMaterialsWindow(); closePlaylistsWindow(); + closeTimerWindow(); closeNpcsWindow(); } @@ -219,6 +225,8 @@ function pageNameForKind(kind: WindowKind): string { return 'materials.html'; case 'playlists': return 'playlists.html'; + case 'timer': + return 'timer.html'; case 'npcsEditor': return 'npcsEditor.html'; case 'sceneEditor': @@ -295,6 +303,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number } 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 === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_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 }; @@ -406,6 +415,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow kind === 'sceneDescription' || kind === 'materials' || kind === 'playlists' || + kind === 'timer' || kind === 'npcsEditor' || kind === 'sceneEditor' || kind === 'tokenPathEditor' || @@ -603,6 +613,13 @@ export function closePlaylistsWindow(): void { } } +export function closeTimerWindow(): void { + const win = windows.get('timer'); + if (win && !win.isDestroyed()) { + win.close(); + } +} + export function closeNpcsEditorWindow(): void { const win = windows.get('npcsEditor'); if (win && !win.isDestroyed()) { @@ -792,6 +809,36 @@ export function openPlaylistsWindow(): void { }); } +export function openTimerWindow(): void { + const existing = windows.get('timer'); + 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('timer', 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 81b7531..025384e 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -61,6 +61,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; +import { SessionTimerLayer } from '../shared/sessionTimer/SessionTimerLayer'; import { useSceneViewState } from '../shared/sceneView/useSceneViewState'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay'; @@ -1842,6 +1843,22 @@ export function ControlApp() { 🎵 + + ))} + + + + +
+ {state.running ? ( + + ) : ( + + )} + +
+ + + +
{t('sessionTimer.positionHint')}
+ + ); +} diff --git a/app/renderer/timer/main.tsx b/app/renderer/timer/main.tsx new file mode 100644 index 0000000..05ab30f --- /dev/null +++ b/app/renderer/timer/main.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import '../shared/ui/globals.css'; +import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; +import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary'; + +import { TimerApp } from './TimerApp'; + +const rootEl = document.getElementById('root'); +if (!rootEl) { + throw new Error('Missing #root element'); +} + +createRoot(rootEl).render( + + + + + + + , +); diff --git a/app/shared/appBranding.ts b/app/shared/appBranding.ts index 038e26a..f63a947 100644 --- a/app/shared/appBranding.ts +++ b/app/shared/appBranding.ts @@ -23,6 +23,7 @@ export type AppWindowKind = | 'sceneDescription' | 'materials' | 'playlists' + | 'timer' | 'npcsEditor' | 'sceneEditor' | 'tokenPathEditor' @@ -36,6 +37,7 @@ const WINDOW_SUFFIX: Record = { sceneDescription: { ru: 'Описание сцены', en: 'Scene description' }, materials: { ru: 'Материалы', en: 'Materials' }, playlists: { ru: 'Плейлисты', en: 'Playlists' }, + timer: { ru: 'Таймер', en: 'Timer' }, 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 c7fb290..0f383cc 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -55,6 +55,8 @@ import type { SceneTrapsState, SceneViewEvent, SceneViewState, + SessionTimerEvent, + SessionTimerState, TokenId, TokenPathSessionEvent, TokenPathSessionState, @@ -163,6 +165,8 @@ export const ipcChannels = { closeMaterials: 'windows.closeMaterials', openPlaylists: 'windows.openPlaylists', closePlaylists: 'windows.closePlaylists', + openTimer: 'windows.openTimer', + closeTimer: 'windows.closeTimer', openNpcsEditor: 'windows.openNpcsEditor', closeNpcsEditor: 'windows.closeNpcsEditor', openNpcs: 'windows.openNpcs', @@ -200,6 +204,11 @@ export const ipcChannels = { dispatch: 'npcsOverlay.dispatch', stateChanged: 'npcsOverlay.stateChanged', }, + sessionTimer: { + getState: 'sessionTimer.getState', + dispatch: 'sessionTimer.dispatch', + stateChanged: 'sessionTimer.stateChanged', + }, sceneDarkness: { getState: 'sceneDarkness.getState', dispatch: 'sceneDarkness.dispatch', @@ -357,6 +366,7 @@ export type IpcEventMap = { [ipcChannels.effects.stateChanged]: { state: EffectsState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; + [ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; @@ -786,6 +796,14 @@ export type IpcInvokeMap = { req: Record; res: { ok: true }; }; + [ipcChannels.windows.openTimer]: { + req: Record; + res: { ok: true }; + }; + [ipcChannels.windows.closeTimer]: { + req: Record; + res: { ok: true }; + }; [ipcChannels.windows.openNpcsEditor]: { req: Record; res: { ok: true }; @@ -842,6 +860,14 @@ export type IpcInvokeMap = { req: { event: NpcsOverlayEvent }; res: { ok: true }; }; + [ipcChannels.sessionTimer.getState]: { + req: Record; + res: { state: SessionTimerState }; + }; + [ipcChannels.sessionTimer.dispatch]: { + req: { event: SessionTimerEvent }; + res: { state: SessionTimerState }; + }; [ipcChannels.effects.getState]: { req: Record; res: { state: EffectsState }; @@ -1094,6 +1120,7 @@ export type LegacyIpcEventMap = { [ipcChannels.effects.stateChanged]: { state: EffectsState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; + [ipcChannels.sessionTimer.stateChanged]: { state: SessionTimerState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 9cac237..945c0e6 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -20,4 +20,5 @@ export * from './scenePreviewRotation'; export * from './presentationSettings'; export * from './sceneTraps'; export * from './sceneView'; +export * from './sessionTimer'; export * from './videoPlayback'; diff --git a/app/shared/types/sessionTimer.ts b/app/shared/types/sessionTimer.ts new file mode 100644 index 0000000..2897a6c --- /dev/null +++ b/app/shared/types/sessionTimer.ts @@ -0,0 +1,128 @@ +import { + clampMaterialsLayout, + type MaterialsOverlayLayout, +} from './materials'; + +/** Раскладка виджета таймера — тот же контракт, что у материалов. */ +export type SessionTimerLayout = MaterialsOverlayLayout; + +export const DEFAULT_SESSION_TIMER_DURATION_MS = 5 * 60 * 1000; +export const SESSION_TIMER_DURATION_MIN_MS = 1_000; +export const SESSION_TIMER_DURATION_MAX_MS = 24 * 60 * 60 * 1000; + +export const DEFAULT_SESSION_TIMER_LAYOUT: SessionTimerLayout = { + cx: 0.92, + cy: 0.08, + scale: 1, + rotationDeg: 0, +}; + +/** Базовая ширина плашки при scale=1 относительно viewport (доли ширины). */ +export const SESSION_TIMER_BASE_WIDTH_FRAC = 0.18; +export const SESSION_TIMER_REF_VIEW_W = 1920; + +export type SessionTimerState = { + revision: number; + durationMs: number; + /** Snapshot оставшегося времени, когда не running (или после стопа на 0). */ + remainingMs: number; + running: boolean; + /** Абсолютный момент окончания (Date.now), когда running. */ + endsAt: number | null; + visibleToPlayers: boolean; + layout: SessionTimerLayout; +}; + +export type SessionTimerEvent = + | { kind: 'setDuration'; durationMs: number } + | { kind: 'start' } + | { kind: 'pause' } + | { kind: 'reset' } + | { kind: 'setVisibleToPlayers'; visible: boolean } + | { kind: 'layout.set'; layout: SessionTimerLayout }; + +export function clampSessionTimerDurationMs(ms: number): number { + if (!Number.isFinite(ms)) return DEFAULT_SESSION_TIMER_DURATION_MS; + return Math.max(SESSION_TIMER_DURATION_MIN_MS, Math.min(SESSION_TIMER_DURATION_MAX_MS, Math.round(ms))); +} + +export function clampSessionTimerLayout(layout: SessionTimerLayout): SessionTimerLayout { + return clampMaterialsLayout(layout); +} + +export function createInitialSessionTimerState(): SessionTimerState { + return { + revision: 1, + durationMs: DEFAULT_SESSION_TIMER_DURATION_MS, + remainingMs: DEFAULT_SESSION_TIMER_DURATION_MS, + running: false, + endsAt: null, + visibleToPlayers: false, + layout: { ...DEFAULT_SESSION_TIMER_LAYOUT }, + }; +} + +/** Оставшееся время в мс на момент `now`. */ +export function getSessionTimerRemainingMs(state: SessionTimerState, now = Date.now()): number { + if (state.running && state.endsAt != null) { + return Math.max(0, state.endsAt - now); + } + return Math.max(0, state.remainingMs); +} + +export function formatSessionTimerMs(ms: number): string { + const totalSec = Math.max(0, Math.ceil(ms / 1000)); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const mm = String(m).padStart(2, '0'); + const ss = String(s).padStart(2, '0'); + if (h > 0) return `${String(h)}:${mm}:${ss}`; + return `${mm}:${ss}`; +} + +/** Парсинг "MM:SS" / "H:MM:SS" / "M:SS" в мс; null если невалидно. */ +export function parseSessionTimerDurationInput(raw: string): number | null { + const t = raw.trim(); + if (!t) return null; + const parts = t.split(':').map((p) => p.trim()); + if (parts.length < 2 || parts.length > 3) return null; + const nums = parts.map((p) => Number(p)); + if (nums.some((n) => !Number.isFinite(n) || n < 0 || !Number.isInteger(n))) return null; + let h = 0; + let m = 0; + let s = 0; + if (parts.length === 3) { + h = nums[0]!; + m = nums[1]!; + s = nums[2]!; + } else { + m = nums[0]!; + s = nums[1]!; + } + if (m > 59 || s > 59) return null; + const ms = ((h * 60 + m) * 60 + s) * 1000; + if (ms < SESSION_TIMER_DURATION_MIN_MS) return null; + return clampSessionTimerDurationMs(ms); +} + +/** + * Пиксельный layout плашки: центр + размер от ширины viewport и scale. + * Одинаковый алгоритм на пульте и презентации → parity. + */ +export function sessionTimerVisualLayout( + layout: SessionTimerLayout, + viewport: { x: number; y: number; w: number; h: number }, +): { left: number; top: number; width: number; height: number; rotationDeg: number } { + const clamped = clampSessionTimerLayout(layout); + const vw = Math.max(1, viewport.w); + const width = Math.max(48, vw * SESSION_TIMER_BASE_WIDTH_FRAC * clamped.scale); + const height = width * 0.42; + return { + left: viewport.x + clamped.cx * vw, + top: viewport.y + clamped.cy * Math.max(1, viewport.h), + width, + height, + rotationDeg: clamped.rotationDeg, + }; +} diff --git a/package.json b/package.json index 42b3218..6556d61 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/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", + "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/sessionTimer/timerSessionStore.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/vite.config.ts b/vite.config.ts index d649a3f..a6f98d6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig(({ mode }) => { sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'), materials: path.resolve(__dirname, 'app/renderer/materials.html'), playlists: path.resolve(__dirname, 'app/renderer/playlists.html'), + timer: path.resolve(__dirname, 'app/renderer/timer.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'),