From f965db148c0c01209ffb4117d4af839e9449fb72 Mon Sep 17 00:00:00 2001 From: Alex Rohleder Date: Fri, 12 Jan 2024 13:03:58 +0100 Subject: [PATCH] refactor: setup global timer state (#629) * refactor: setup global timer state --------- Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Carlos Valente --- apps/server/package.json | 2 + apps/server/src/app.ts | 43 +- apps/server/src/services/PlaybackService.ts | 21 +- apps/server/src/services/TimerService.ts | 471 ++-------------- .../rundown-service/RundownService.ts | 3 +- apps/server/src/services/timerUtils.ts | 2 + apps/server/src/setup.ts | 2 +- apps/server/src/state.ts | 517 ++++++++++++++++++ apps/server/src/stores/EventStore.ts | 9 +- .../src/utils/__tests__/sheetUtils.test.ts | 23 +- e2e/tests/000-smoke-tests.spec.ts | 3 +- pnpm-lock.yaml | 23 +- 12 files changed, 666 insertions(+), 453 deletions(-) create mode 100644 apps/server/src/state.ts diff --git a/apps/server/package.json b/apps/server/package.json index bfa529884..9b9cb18db 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,8 @@ "ontime-utils": "workspace:*", "passport": "^0.6.0", "passport-local": "~1.0.0", + "steno": "^3.1.0", + "ts-essentials": "^9.4.1", "ws": "^8.13.0" }, "devDependencies": { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 8530791e7..7b53970af 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,4 @@ -import { HttpSettings, LogOrigin, OSCSettings } from 'ontime-types'; +import { HttpSettings, LogOrigin, OSCSettings, Playback } from 'ontime-types'; import 'dotenv/config'; import express from 'express'; @@ -38,11 +38,12 @@ import { logger } from './classes/Logger.js'; import { oscIntegration } from './services/integration-service/OscIntegration.js'; import { httpIntegration } from './services/integration-service/HttpIntegration.js'; import { populateStyles } from './modules/loadStyles.js'; -import { eventStore, getInitialPayload } from './stores/EventStore.js'; +import { eventStore } from './stores/EventStore.js'; import { PlaybackService } from './services/PlaybackService.js'; -import { RestorePoint, restoreService } from './services/RestoreService.js'; +import { restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; import { populateDemo } from './modules/loadDemo.js'; +import { state } from './state.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -158,17 +159,41 @@ export const startServer = async () => { // load restore point if it exists const maybeRestorePoint = await restoreService.load(); - if (maybeRestorePoint) { logger.info(LogOrigin.Server, 'Found resumable state'); PlaybackService.resume(maybeRestorePoint); } + eventTimer.init(); - eventTimer.setRestoreCallback(async (newState: RestorePoint) => restoreService.save(newState)); - - // provide initial payload to event store - const initialPayload = getInitialPayload(); - eventStore.init(initialPayload); + /** + * Module initialises the services and provides initial payload for the store + * Currently registered objects in store + * - Timer Service timer + * - Timer Service playback + * - Message Service timerMessage + * - Message Service publicMessage + * - Message Service lowerMessage + * - Message Service onAir + * - Event Loader loaded + * - Event Loader eventNow + * - Event Loader publicEventNow + * - Event Loader eventNext + * - Event Loader publicEventNext + */ + eventStore.init({ + timer: state.timer, + playback: state.playback, + timerMessage: messageService.timerMessage, + publicMessage: messageService.publicMessage, + lowerMessage: messageService.lowerMessage, + externalMessage: messageService.externalMessage, + onAir: state.playback !== Playback.Stop, + loaded: eventLoader.loaded, + eventNow: eventLoader.eventNow, + publicEventNow: eventLoader.publicEventNow, + eventNext: eventLoader.eventNext, + publicEventNext: eventLoader.publicEventNext, + }); // eventStore set is a dependency of the services that publish to it messageService.init(eventStore.set.bind(eventStore)); diff --git a/apps/server/src/services/PlaybackService.ts b/apps/server/src/services/PlaybackService.ts index 69ddbde9e..60051531e 100644 --- a/apps/server/src/services/PlaybackService.ts +++ b/apps/server/src/services/PlaybackService.ts @@ -7,6 +7,7 @@ import { eventTimer } from './TimerService.js'; import { clock } from './Clock.js'; import { logger } from '../classes/Logger.js'; import { RestorePoint } from './RestoreService.js'; +import { state } from '../state.js'; /** * Service manages playback status of app @@ -164,9 +165,9 @@ export class PlaybackService { * Starts playback on selected event */ static start() { - if (validatePlayback(eventTimer.playback).start) { + if (validatePlayback(state.playback).start) { eventTimer.start(); - const newState = eventTimer.playback; + const newState = state.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); } } @@ -186,9 +187,9 @@ export class PlaybackService { * Pauses playback on selected event */ static pause() { - if (validatePlayback(eventTimer.playback).pause) { + if (validatePlayback(state.playback).pause) { eventTimer.pause(); - const newState = eventTimer.playback; + const newState = state.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); } } @@ -197,10 +198,10 @@ export class PlaybackService { * Stops timer and unloads any events */ static stop() { - if (validatePlayback(eventTimer.playback).stop) { + if (validatePlayback(state.playback).stop) { eventLoader.reset(); eventTimer.stop(); - const newState = eventTimer.playback; + const newState = state.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); } } @@ -209,8 +210,8 @@ export class PlaybackService { * Reloads current event */ static reload() { - if (eventTimer.loadedTimerId) { - this.loadById(eventTimer.loadedTimerId); + if (state.timer.selectedEventId) { + this.loadById(state.timer.selectedEventId); } } @@ -237,7 +238,7 @@ export class PlaybackService { eventTimer.roll(currentEvent, nextEvent); - const newState = eventTimer.playback; + const newState = state.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); } } @@ -274,7 +275,7 @@ export class PlaybackService { * @param {number} time - time to add in seconds */ static addTime(time: number) { - if (eventTimer.loadedTimerId) { + if (state.timer.selectedEventId) { const timeInMs = time * 1000; eventTimer.addTime(timeInMs); timeInMs > 0 diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index e40fc4167..8ea92605c 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,14 +1,8 @@ -import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types'; -import { calculateDuration, dayInMs } from 'ontime-utils'; +import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; -import { eventStore } from '../stores/EventStore.js'; -import { PlaybackService } from './PlaybackService.js'; -import { updateRoll } from './rollUtils.js'; -import { integrationService } from './integration-service/IntegrationService.js'; -import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './timerUtils.js'; -import { clock } from './Clock.js'; import { logger } from '../classes/Logger.js'; import type { RestorePoint } from './RestoreService.js'; +import { stateMutations, state } from '../state.js'; type initialLoadingData = { startedAt?: number | null; @@ -16,302 +10,111 @@ type initialLoadingData = { current?: number | null; }; -type RestoreCallback = (newState: RestorePoint) => Promise; - -export const timeSkipLimit = 3 * 32; - export class TimerService { - private readonly _interval: NodeJS.Timer; + private _interval: NodeJS.Timer; private _updateInterval: number; - private _lastUpdate: number | null; - private _skipThreshold: number; + private _refreshInterval: number; - playback: Playback; - timer: TimerState; - - loadedTimerId: string | null; - private loadedTimerStart: number | null; - private loadedTimerEnd: number | null; - - private pausedTime: number; - private pausedAt: number | null; - private secondaryTarget: number | null; - - private saveRestorePoint: RestoreCallback; /** * @constructor * @param {object} [timerConfig] * @param {number} [timerConfig.refresh] * @param {number} [timerConfig.updateInterval] - * @param {number} [timerConfig.skipThreshold] */ - constructor(timerConfig: { refresh: number; updateInterval: number; skipThreshold: number }) { - this._clear(); - this._interval = setInterval(() => this.update(), timerConfig.refresh); + constructor(timerConfig: { refresh: number; updateInterval: number }) { + this._refreshInterval = timerConfig.refresh; this._updateInterval = timerConfig.updateInterval; - this._skipThreshold = timerConfig.skipThreshold; + logger.info(LogOrigin.Server, 'Timer service started'); } - /** - * Provides callback to save restore point - * @param cb - */ - setRestoreCallback(cb: RestoreCallback) { - this.saveRestorePoint = cb; - } - - /** - * Clears internal state - * @private - */ - _clear() { - this.playback = Playback.Stop; - this.timer = { - clock: clock.timeNow(), - current: null, - elapsed: null, - expectedFinish: null, - addedTime: 0, - startedAt: null, - finishedAt: null, - secondaryTimer: null, - selectedEventId: null, - duration: null, - timerType: null, - endAction: null, - timeWarning: null, - timeDanger: null, - }; - this.loadedTimerId = null; - this.loadedTimerStart = null; - this.loadedTimerEnd = null; - - this.pausedTime = 0; - this.pausedAt = null; - this.secondaryTarget = null; - - this._lastUpdate = null; + init() { + this._interval = setInterval(() => this.update(), this._refreshInterval); } /** * Resumes a given playback state, same as load * @param {RestorePoint} restorePoint - * @param {OntimeEvent} timer + * @param {OntimeEvent} event */ - resume(timer: OntimeEvent, restorePoint: RestorePoint) { - this._clear(); - - // this is pretty much the same as load, with a few exceptions - this.loadedTimerId = timer.id; - this.loadedTimerStart = timer.timeStart; - this.loadedTimerEnd = timer.timeEnd; - - this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd); - this.playback = restorePoint.playback; - this.timer.timerType = timer.timerType; - this.timer.endAction = timer.endAction; - this.timer.startedAt = restorePoint.startedAt; - this.timer.addedTime = restorePoint.addedTime; - this.pausedTime = 0; - this.pausedAt = restorePoint.pausedAt; - - this.timer.current = this.timer.duration; - if (this.timer.timerType === TimerType.TimeToEnd) { - const now = clock.timeNow(); - this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType); - } - - this._onResume(); - } - - _onResume() { - eventStore.batchSet({ - playback: this.playback, - timer: this.timer, - }); + resume(event: OntimeEvent, restorePoint: RestorePoint) { + stateMutations.timer.resume(event, restorePoint); } + // TODO: can load and hotreload be merged? /** * Reloads information for currently running timer - * @param timer + * @param event */ - hotReload(timer) { - if (typeof timer === 'undefined') { + hotReload(event: OntimeEvent | undefined) { + if (event === undefined) { this.stop(); return; } - if (timer?.id !== this.loadedTimerId) { - // event timer only concerns itself with current event + // TODO: this is no longer correct + if (event.id !== state.timer.selectedEventId) { + // we only hot reload if the timer is the same return; } - if (timer?.skip) { + if (event.skip) { this.stop(); } // TODO: check if any relevant information warrants update + stateMutations.timer.reload(event); - // update relevant information and force update - this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd); - this.timer.timerType = timer.timerType; - this.timer.endAction = timer.endAction; - this.loadedTimerStart = timer.timeStart; - this.loadedTimerEnd = timer.timeEnd; - this.timer.timeWarning = timer.timeWarning; - this.timer.timeDanger = timer.timeDanger; - - // this might not be ideal - this.timer.finishedAt = null; - this.timer.expectedFinish = getExpectedFinish( - this.timer.startedAt, - this.timer.finishedAt, - this.timer.duration, - this.pausedTime, - this.timer.addedTime, - this.loadedTimerEnd, - this.timer.timerType, - ); - if (this.timer.startedAt === null) { - this.timer.current = this.timer.duration; - } this.update(true); } /** * Loads given timer to object - * @param {OntimeEvent} timer + * @param {OntimeEvent} event * @param {initialLoadingData} initialData */ - load(timer: OntimeEvent, initialData?: initialLoadingData) { - if (timer.skip) { + load(event: OntimeEvent, initialData?: initialLoadingData) { + if (event.skip) { throw new Error('Refuse load of skipped event'); } - this._clear(); - - this.loadedTimerId = timer.id; - this.loadedTimerStart = timer.timeStart; - this.loadedTimerEnd = timer.timeEnd; - - this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd); - this.playback = Playback.Armed; - this.timer.timerType = timer.timerType; - this.timer.endAction = timer.endAction; - this.pausedTime = 0; - this.pausedAt = 0; - this.timer.timeWarning = timer.timeWarning; - this.timer.timeDanger = timer.timeDanger; - - this.timer.current = this.timer.duration; - if (this.timer.timerType === TimerType.TimeToEnd) { - const now = clock.timeNow(); - this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType); - } + stateMutations.timer.clear(); + // TODO: does this replace the need for hot reload? if (initialData) { - this.timer = { ...this.timer, ...initialData }; + stateMutations.timer.patch(initialData); } - this._onLoad(); - } - - /** - * Handles side effects related to onLoad event - * @private - */ - _onLoad() { - eventStore.batchSet({ - playback: this.playback, - timer: this.timer, - }); - integrationService.dispatch(TimerLifeCycle.onLoad); - this._saveState(); + stateMutations.timer.load(event); } start() { - if (!this.loadedTimerId) { - if (this.playback === Playback.Roll) { + if (!state.timer.selectedEventId) { + // TODO: we should be able to start + if (state.playback === Playback.Roll) { logger.error(LogOrigin.Playback, 'Cannot start while waiting for event'); } return; } - if (this.playback === Playback.Play) { + if (state.playback === Playback.Play) { return; } - this.timer.clock = clock.timeNow(); - this.timer.secondaryTimer = null; - this.secondaryTarget = null; - - // add paused time if it exists - if (this.pausedTime) { - this.timer.addedTime += this.pausedTime; - this.pausedAt = null; - this.pausedTime = 0; - } else if (this.timer.startedAt === null) { - this.timer.startedAt = this.timer.clock; - } - - this.playback = Playback.Play; - this.timer.expectedFinish = getExpectedFinish( - this.timer.startedAt, - this.timer.finishedAt, - this.timer.duration, - this.pausedTime, - this.timer.addedTime, - this.loadedTimerEnd, - this.timer.timerType, - ); - this._onStart(); - } - - /** - * Handles side effects related to onStart event - * @private - */ - _onStart() { - eventStore.batchSet({ - playback: this.playback, - timer: this.timer, - }); - integrationService.dispatch(TimerLifeCycle.onStart); - this._saveState(); + stateMutations.timer.start(); } pause() { - this.playback = Playback.Pause; - this.timer.clock = clock.timeNow(); - this.pausedAt = this.timer.clock; - this._onPause(); - } - - _onPause() { - eventStore.batchSet({ - playback: this.playback, - timer: this.timer, - }); - integrationService.dispatch(TimerLifeCycle.onPause); - this._saveState(); + if (state.playback !== Playback.Play) { + return; + } + stateMutations.timer.pause(); } stop() { - if (this.playback === Playback.Stop) { + if (state.playback === Playback.Stop) { return; } - - this._clear(); - this._onStop(); - } - - _onStop() { - eventStore.batchSet({ - playback: this.playback, - timer: this.timer, - }); - integrationService.dispatch(TimerLifeCycle.onStop); - this._saveState(); + stateMutations.timer.stop(); } /** @@ -319,152 +122,17 @@ export class TimerService { * @param {number} amount */ addTime(amount: number) { - if (!this.loadedTimerId) { + if (amount === 0) { return; } - - this.timer.addedTime += amount; - - // handle edge cases - if (amount < 0 && Math.abs(amount) > this.timer.current) { - if (this.timer.finishedAt === null) { - // if we will make the clock negative - this.timer.finishedAt = clock.timeNow(); - } - } else if (this.timer.current < 0 && this.timer.current + amount > 0) { - // clock will go from negative to positive - this.timer.finishedAt = null; + if (state.timer.selectedEventId === null) { + return; } - - // force an update - this.update(true); - this._saveState(); - } - - private updateRoll() { - const tempCurrentTimer = { - selectedEventId: this.loadedTimerId, - current: this.timer.current, - // safeguard on midnight rollover - _finishAt: - this.timer.expectedFinish >= this.timer.startedAt - ? this.timer.expectedFinish - : this.timer.expectedFinish + dayInMs, - clock: this.timer.clock, - secondaryTimer: this.timer.secondaryTimer, - secondaryTarget: this.secondaryTarget, - }; - const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer); - - this.timer.current = updatedTimer; - this.timer.secondaryTimer = updatedSecondaryTimer; - this.timer.elapsed = this.timer.duration - this.timer.current; - - if (isFinished) { - this.timer.selectedEventId = null; - this.loadedTimerId = null; - this._onFinish(); - } - - // to load the next event we have to escalate to parent service - if (doRollLoad) { - PlaybackService.roll(); - } - } - - private updatePlay() { - if (this.playback === Playback.Pause) { - this.pausedTime = this.timer.clock - this.pausedAt; - } - - const finishedNow = this.timer.current <= 0 && this.timer.finishedAt === null; - if (this.playback === Playback.Play && finishedNow) { - this.timer.finishedAt = this.timer.clock; - this._onFinish(); - } else { - this.timer.expectedFinish = getExpectedFinish( - this.timer.startedAt, - this.timer.finishedAt, - this.timer.duration, - this.pausedTime, - this.timer.addedTime, - this.loadedTimerEnd, - this.timer.timerType, - ); - } - this.timer.current = getCurrent( - this.timer.startedAt, - this.timer.duration, - this.timer.addedTime, - this.pausedTime, - this.timer.clock, - this.loadedTimerEnd, - this.timer.timerType, - ); - this.timer.elapsed = this.timer.duration - this.timer.current; + stateMutations.timer.addTime(amount); } update(force = false) { - const previousTime = this.timer.clock; - this.timer.clock = clock.timeNow(); - if (previousTime > this.timer.clock) { - force = true; - } - - // we call integrations if we update timers - let shouldNotify = false; - if (this.playback === Playback.Roll) { - shouldNotify = true; - if ( - skippedOutOfEvent( - previousTime, - this.timer.clock, - this.timer.startedAt, - this.timer.expectedFinish, - this._skipThreshold, - ) - ) { - PlaybackService.roll(); - } else { - this.updateRoll(); - } - } else if (this.timer.startedAt !== null) { - // we only update timer if a timer has been started - shouldNotify = true; - this.updatePlay(); - } - - // we only update the store at the updateInterval - // side effects such as onFinish will still be triggered in the update functions - if (force || this.timer.clock > this._lastUpdate + this._updateInterval) { - this._lastUpdate = this.timer.clock; - this._onUpdate(shouldNotify); - } - } - - _onUpdate(shouldNotify: boolean) { - eventStore.set('timer', this.timer); - if (shouldNotify) { - integrationService.dispatch(TimerLifeCycle.onUpdate); - } - } - - _onFinish() { - eventStore.set('timer', this.timer); - integrationService.dispatch(TimerLifeCycle.onFinish); - if (this.playback === Playback.Play) { - if (this.timer.endAction === EndAction.Stop) { - PlaybackService.stop(); - } else if (this.timer.endAction === EndAction.LoadNext) { - // we need to delay here to put this action in the queue stack. otherwise it won't be executed properly - setTimeout(() => { - PlaybackService.loadNext(); - }, 0); - } else if (this.timer.endAction === EndAction.PlayNext) { - PlaybackService.startNext(); - } - } - this._saveState(); + stateMutations.timer.update(force, this._updateInterval); } /** @@ -473,52 +141,8 @@ export class TimerService { * @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null */ roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) { - this._clear(); - this.timer.clock = clock.timeNow(); - - if (currentEvent) { - // there is something running, load - this.timer.secondaryTimer = null; - this.secondaryTarget = null; - - // account for event that finishes the day after - const endTime = - currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd; - - // when we load a timer in roll, we do the same things as before - // but also pre-populate some data as to the running state - this.load(currentEvent, { - startedAt: currentEvent.timeStart, - expectedFinish: currentEvent.timeEnd, - current: endTime - this.timer.clock, - }); - } else if (nextEvent) { - // account for day after - const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; - // nothing now, but something coming up - this.timer.secondaryTimer = nextStart - this.timer.clock; - this.secondaryTarget = nextStart; - } - this.playback = Playback.Roll; - this._onRoll(); - this.update(true); - } - - _onRoll() { - eventStore.set('playback', this.playback); - this._saveState(); - } - - async _saveState() { - if (this.saveRestorePoint) { - await this.saveRestorePoint({ - playback: this.playback, - selectedEventId: this.loadedTimerId, - startedAt: this.timer.startedAt, - addedTime: this.timer.addedTime, - pausedAt: this.pausedAt, - }); - } + stateMutations.timer.roll(currentEvent, nextEvent); + this.update(); } shutdown() { @@ -527,5 +151,4 @@ export class TimerService { } // calculate at 30fps, refresh at 1fps -// we consider a skip at 3 lost updates -export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000, skipThreshold: 32 * 3 }); +export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 }); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index e2efb97d0..060a50c2c 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -28,6 +28,7 @@ import { import { logger } from '../../classes/Logger.js'; import { validateEvent } from '../../utils/parser.js'; import { clock } from '../Clock.js'; +import { state } from '../../state.js'; /** * Forces rundown to be recalculated @@ -123,7 +124,7 @@ export function updateTimer(affectedIds?: string[]) { if (eventInMemory) { eventLoader.reset(); - if (eventTimer.playback === Playback.Roll) { + if (state.playback === Playback.Roll) { const rollTimers = eventLoader.findRoll(clock.timeNow()); if (rollTimers === null) { eventTimer.stop(); diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index a657389f5..5344eda8c 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,6 +1,8 @@ import { MaybeNumber, TimerType } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; +// TODO: timerUtils receive entire state object + /** * Calculates expected finish time of a running timer */ diff --git a/apps/server/src/setup.ts b/apps/server/src/setup.ts index 8485c2a8a..559d96a60 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup.ts @@ -99,7 +99,7 @@ export const pathToStartDb = isTest ? join(currentDirectory, '../', config.database.testdb, config.database.filename) : join(currentDirectory, '/preloaded-db/', config.database.filename); -//TODO: move all static files to the external directory +// TODO: move all static files to the external directory // path to public styles export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory); export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename); diff --git a/apps/server/src/state.ts b/apps/server/src/state.ts new file mode 100644 index 000000000..0d71f3a2f --- /dev/null +++ b/apps/server/src/state.ts @@ -0,0 +1,517 @@ +import type { DeepReadonly, DeepWritable } from 'ts-essentials'; + +import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types'; +import { calculateDuration, dayInMs } from 'ontime-utils'; + +import { clock } from './services/Clock.js'; +import { RestorePoint, restoreService } from './services/RestoreService.js'; +import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './services/timerUtils.js'; +import { eventStore } from './stores/EventStore.js'; +import { integrationService } from './services/integration-service/IntegrationService.js'; +import { PlaybackService } from './services/PlaybackService.js'; +import { updateRoll } from './services/rollUtils.js'; + +// TODO: move to timer config +const timeSkipLimit = 3 * 32; + +type TState = DeepReadonly<{ + playback: Playback; + timer: TimerState & { + pausedTime: number; + pausedAt: number | null; + timeEnd: number | null; + finishedNow: boolean; + lastUpdate: number | null; + secondaryTarget: number | null; + }; +}>; + +export const state: TState = { + // QUESTION: should merge playback into the timer? + playback: Playback.Stop, + timer: { + clock: clock.timeNow(), + current: null, + elapsed: null, + expectedFinish: null, + addedTime: 0, + pausedTime: 0, + pausedAt: null, + timeEnd: null, + startedAt: null, + finishedAt: null, + secondaryTimer: null, + selectedEventId: null, + duration: null, + timerType: null, + endAction: null, + lastUpdate: null, + secondaryTarget: null, + timeWarning: null, + timeDanger: null, + get finishedNow() { + return this.current <= 0 && this.finishedAt === null; + }, + }, +}; + +export const stateMutations = { + timer: { + // utility to reset the state of the timer + clear() { + mutate((state) => { + // TODO: check that entire state is reset here + state.playback = Playback.Stop; + state.timer.clock = clock.timeNow(); + state.timer.current = null; + state.timer.elapsed = null; + state.timer.expectedFinish = null; + state.timer.addedTime = 0; + state.timer.startedAt = null; + state.timer.finishedAt = null; + state.timer.secondaryTimer = null; + state.timer.selectedEventId = null; + state.timer.duration = null; + state.timer.timerType = null; + state.timer.endAction = null; + + state.timer.pausedTime = 0; + state.timer.pausedAt = null; + state.timer.secondaryTarget = null; + }); + }, + // utility to allow modifying the state from the outside + patch(timer: Partial) { + mutate((state) => { + for (const key in timer) { + if (key in state.timer) { + state.timer[key] = timer[key]; + } + } + }); + }, + start() { + mutate( + (state) => { + // TODO: we need an event to start, should we get the event or the whole rundown? + + state.timer.clock = clock.timeNow(); + state.timer.secondaryTimer = null; + state.timer.secondaryTarget = null; + + // add paused time if it exists + if (state.timer.pausedTime) { + state.timer.addedTime += state.timer.pausedTime; + state.timer.pausedAt = null; + state.timer.pausedTime = 0; + } else if (state.timer.startedAt === null) { + state.timer.startedAt = state.timer.clock; + } + + state.playback = Playback.Play; + state.timer.expectedFinish = getExpectedFinish( + state.timer.startedAt, + state.timer.finishedAt, + state.timer.duration, + state.timer.pausedTime, + state.timer.addedTime, + state.timer.timeEnd, + state.timer.timerType, + ); + }, + { + sideEffect() { + integrationService.dispatch(TimerLifeCycle.onStart); + }, + }, + ); + }, + stop() { + mutate( + (_state) => { + stateMutations.timer.clear(); + }, + { + sideEffect() { + integrationService.dispatch(TimerLifeCycle.onStop); + }, + }, + ); + }, + pause() { + mutate( + (state) => { + // TODO: is it easier to have a beforeAll() function that sets the timer? + state.playback = Playback.Pause; + state.timer.clock = clock.timeNow(); + state.timer.pausedAt = state.timer.clock; + }, + { + sideEffect() { + integrationService.dispatch(TimerLifeCycle.onPause); + }, + }, + ); + }, + resume(event: OntimeEvent, restorePoint: RestorePoint) { + mutate((state) => { + state.timer.clock = clock.timeNow(); + + // TODO: the duplication of timer data would not be necessary + // once event loader is merged here + state.timer.selectedEventId = event.id; + state.timer.startedAt = restorePoint.startedAt; + state.timer.timeEnd = event.timeEnd; + state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); + state.timer.current = state.timer.duration; + + state.timer.timerType = event.timerType; + state.timer.endAction = event.endAction; + + state.playback = restorePoint.playback; + state.timer.pausedAt = restorePoint.pausedAt; + state.timer.addedTime = restorePoint.addedTime; + + // check if event finished meanwhile + if (state.timer.timerType === TimerType.TimeToEnd) { + state.timer.current = getCurrent( + state.timer.startedAt, + state.timer.duration, + 0, + 0, + state.timer.clock, + event.timeEnd, + state.timer.timerType, + ); + } + }); + }, + load(event: OntimeEvent, patch?: Partial) { + mutate( + (state) => { + // TODO: resume and load logic are very similar + state.timer.clock = clock.timeNow(); + state.timer.selectedEventId = event.id; + state.timer.timeEnd = event.timeEnd; + + state.playback = Playback.Armed; + state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); + state.timer.timerType = event.timerType; + state.timer.endAction = event.endAction; + state.timer.timeWarning = event.timeWarning; + state.timer.timeDanger = event.timeDanger; + state.timer.pausedTime = 0; + state.timer.pausedAt = 0; // TODO: should this not be null? + + state.timer.current = state.timer.duration; + if (state.timer.timerType === TimerType.TimeToEnd) { + state.timer.current = getCurrent( + state.timer.clock, + state.timer.duration, + 0, + 0, + state.timer.clock, + event.timeEnd, + state.timer.timerType, + ); + } + + if (patch) { + state.timer = { ...state.timer, ...patch }; + } + }, + { + sideEffect() { + integrationService.dispatch(TimerLifeCycle.onLoad); + }, + }, + ); + }, + reload(timer: OntimeEvent) { + mutate((state) => { + state.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd); + state.timer.timerType = timer.timerType; + state.timer.endAction = timer.endAction; + state.timer.timeEnd = timer.timeEnd; + + state.timer.finishedAt = null; + state.timer.expectedFinish = getExpectedFinish( + state.timer.startedAt, + state.timer.finishedAt, + state.timer.duration, + state.timer.pausedTime, + state.timer.addedTime, + state.timer.timeEnd, + state.timer.timerType, + ); + + if (state.timer.startedAt === null) { + state.timer.current = state.timer.duration; + } + }); + }, + addTime(amount: number) { + mutate((state) => { + // TODO: what kinds of validation go here or in the consumer? + if (!state.timer.selectedEventId) { + return; + } + + // TODO: remove pausedTime in favour of addedTime + state.timer.addedTime += amount; + + // handle edge cases + const willGoNegative = amount < 0 && Math.abs(amount) > state.timer.current; + if (willGoNegative) { + if (state.timer.finishedAt === null) { + state.timer.finishedAt = clock.timeNow(); + } + } else { + const willGoPositive = state.timer.current < 0 && state.timer.current + amount > 0; + if (willGoPositive) { + state.timer.finishedAt = null; + } + } + }); + }, + // TODO: make options an object + update(force: boolean, updateInterval: number) { + return mutate( + (state) => { + function roll() { + const hasSkippedOutOfEvent = skippedOutOfEvent( + previousTime, + state.timer.clock, + state.timer.startedAt, + state.timer.expectedFinish, + timeSkipLimit, + ); + if (hasSkippedOutOfEvent) { + return { doRoll: true }; + } + + const tempCurrentTimer = { + selectedEventId: state.timer.selectedEventId, + current: state.timer.current, + // safeguard on midnight rollover + _finishAt: + state.timer.expectedFinish >= state.timer.startedAt + ? state.timer.expectedFinish + : state.timer.expectedFinish + dayInMs, + clock: state.timer.clock, + secondaryTimer: state.timer.secondaryTimer, + secondaryTarget: state.timer.secondaryTarget, + }; + + const updated = updateRoll(tempCurrentTimer); + const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updated; + state.timer.current = updatedTimer; + state.timer.secondaryTimer = updatedSecondaryTimer; + state.timer.elapsed = state.timer.duration - state.timer.current; + + if (isFinished) { + state.timer.selectedEventId = null; + } + + return { doRoll: doRollLoad, isFinished }; + } + + function play() { + if (state.playback === Playback.Pause) { + state.timer.pausedTime = state.timer.clock - state.timer.pausedAt; + } + + let isFinished = false; + + if (state.playback === Playback.Play && state.timer.finishedNow) { + state.timer.finishedAt = state.timer.clock; + isFinished = true; + } else { + state.timer.expectedFinish = getExpectedFinish( + state.timer.startedAt, + state.timer.finishedAt, + state.timer.duration, + state.timer.pausedTime, + state.timer.addedTime, + state.timer.timeEnd, + state.timer.timerType, + ); + } + + state.timer.current = getCurrent( + state.timer.startedAt, + state.timer.duration, + state.timer.addedTime, + state.timer.pausedTime, + state.timer.clock, + state.timer.timeEnd, + state.timer.timerType, + ); + + state.timer.elapsed = state.timer.duration - state.timer.current; + + return { isFinished }; + } + + // force indicates whether the state change should be broadcast to socket + let _force = force; + let _didUpdate = false; + let _doRoll = false; + let _isFinished = false; + let _shouldNotify = false; + + const previousTime = state.timer.clock; + state.timer.clock = clock.timeNow(); + + if (previousTime > state.timer.clock) { + _force = true; + } + + // we call integrations if we update timers + if (state.playback === Playback.Roll) { + const result = roll(); + _shouldNotify = true; + _doRoll = result.doRoll; + _isFinished = result.isFinished; + } else if (state.timer.startedAt !== null) { + // we only update timer if a timer has been started + const result = play(); + _shouldNotify = true; + _isFinished = result.isFinished; + } + + // we only update the store at the updateInterval + // side effects such as onFinish will still be triggered in the update functions + const isTimeToUpdate = state.timer.clock > state.timer.lastUpdate + updateInterval; + if (_force || isTimeToUpdate) { + state.timer.lastUpdate = state.timer.clock; + // TODO: can we simplify the didUpdate and shouldNotify + _didUpdate = true; + } + + return { + didUpdate: _didUpdate, + doRoll: _doRoll, + isFinished: _isFinished, + shouldNotify: _shouldNotify, + }; + }, + { + sideEffect({ didUpdate, doRoll, isFinished, shouldNotify }, newState) { + // TODO: we cant cyclic calls to PlaybackService + if (didUpdate && shouldNotify) { + // TODO: can we distinguish between a clock update and a timer update? + integrationService.dispatch(TimerLifeCycle.onUpdate); + } + + if (doRoll) { + PlaybackService.roll(); + } + + if (isFinished) { + integrationService.dispatch(TimerLifeCycle.onFinish); + + // handle end action if there was a timer playing + if (newState.playback === Playback.Play) { + if (newState.timer.endAction === EndAction.Stop) { + PlaybackService.stop(); + } else if (newState.timer.endAction === EndAction.LoadNext) { + // we need to delay here to put this action in the queue stack. otherwise it won't be executed properly + setTimeout(PlaybackService.loadNext, 0); + } else if (newState.timer.endAction === EndAction.PlayNext) { + PlaybackService.startNext(); + } + } + } + }, + }, + ); + }, + roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) { + mutate((state) => { + stateMutations.timer.clear(); + // TODO: should we have a pre-action that updates time in all mutations? + state.timer.clock = clock.timeNow(); + + if (currentEvent) { + // there is something running, load + state.timer.secondaryTimer = null; + state.timer.secondaryTarget = null; + + // account for event that finishes the day after + const endTime = + currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd; + + // when we load a timer in roll, we do the same things as before + // but also pre-populate some data as to the running state + this.load(currentEvent, { + startedAt: currentEvent.timeStart, + expectedFinish: currentEvent.timeEnd, + current: endTime - state.timer.clock, + }); + } else if (nextEvent) { + // account for day after + const nextStart = + nextEvent.timeStart < state.timer.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; + // nothing now, but something coming up + state.timer.secondaryTimer = nextStart - state.timer.clock; + state.timer.secondaryTarget = nextStart; + } + state.playback = Playback.Roll; + }); + }, + }, +}; + +/** + * This function is the only way to write to the state. + * Mock this in the unit tests to assert state transitions and side effects. + */ +function mutate( + /** + * A pure function that receives the current state and modifies it in place. + * The function can return a value that will be passed to the side effects, and + * will be returned to the caller of the mutation. + */ + fn: (state: DeepWritable) => R, + opts?: { + /** + * Side effects are functions that execute code related to the change in state. + * + * @param result The result of the mutation function `fn` + * @param newState The new state after the mutation + * @param prevState The state before the mutation + */ + sideEffect?: (result: R, newState: TState, prevState: TState) => void; + }, +) { + const prevState = state; + + const result = fn(state as DeepWritable); + + const newState = state; + + // run action specific side effect before global side effects + opts?.sideEffect?.(result, newState, prevState); + + // TODO: how would we handle granular updates + // once more state is migrated here + + // set eventStore any time a mutation happens + // this means we are pushing this data to the client every 32ms + eventStore.batchSet({ + playback: newState.playback, + timer: newState.timer, + }); + + // we write to restore service if the underlying data changes + restoreService.save({ + playback: state.playback, + selectedEventId: state.timer.selectedEventId, + startedAt: state.timer.startedAt, + addedTime: state.timer.addedTime, + pausedAt: state.timer.pausedAt, + }); + + return result; +} diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts index bcee1c0bd..d5fc15592 100644 --- a/apps/server/src/stores/EventStore.ts +++ b/apps/server/src/stores/EventStore.ts @@ -1,8 +1,9 @@ import { Playback, RuntimeStore } from 'ontime-types'; + import { socket } from '../adapters/WebsocketAdapter.js'; -import { eventTimer } from '../services/TimerService.js'; import { messageService } from '../services/message-service/MessageService.js'; import { eventLoader } from '../classes/event-loader/EventLoader.js'; +import { state } from '../state.js'; export type PublishFn = (key: T, value: RuntimeStore[T]) => void; @@ -65,9 +66,9 @@ export const eventStore = { */ export const getInitialPayload = () => ({ - timer: eventTimer.timer, - playback: eventTimer.playback, - onAir: eventTimer.playback !== Playback.Stop, + timer: state.timer, + playback: state.playback, + onAir: state.playback !== Playback.Stop, timerMessage: messageService.timerMessage, publicMessage: messageService.publicMessage, lowerMessage: messageService.lowerMessage, diff --git a/apps/server/src/utils/__tests__/sheetUtils.test.ts b/apps/server/src/utils/__tests__/sheetUtils.test.ts index 63708a66b..a21598fb1 100644 --- a/apps/server/src/utils/__tests__/sheetUtils.test.ts +++ b/apps/server/src/utils/__tests__/sheetUtils.test.ts @@ -1,6 +1,7 @@ -import { millisToString } from 'ontime-utils'; -import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js'; import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types'; +import { millisToString } from 'ontime-utils'; + +import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js'; describe('getA1Notation()', () => { test('A1', () => { @@ -46,6 +47,8 @@ describe('cellRequenstFromEvent()', () => { user9: '', revision: 0, id: '1358', + timeWarning: 0, + timeDanger: 0, }; const metadata = { type: { row: 1, col: 14 }, @@ -74,6 +77,8 @@ describe('cellRequenstFromEvent()', () => { user9: { row: 1, col: 37 }, revision: { row: 1, col: 38 }, id: { row: 1, col: 39 }, + timeWarning: { row: 1, col: 40 }, + timeDanger: { row: 1, col: 41 }, }; const result = cellRequestFromEvent(event, 1, 1234, metadata); expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note); @@ -107,6 +112,8 @@ describe('cellRequenstFromEvent()', () => { user9: '', revision: 0, id: '1358', + timeWarning: 0, + timeDanger: 0, }; const metadata = { type: { row: 1, col: 14 }, @@ -135,6 +142,8 @@ describe('cellRequenstFromEvent()', () => { user9: { row: 1, col: 37 }, revision: { row: 1, col: 38 }, id: { row: 1, col: 39 }, + timeWarning: { row: 1, col: 40 }, + timeDanger: { row: 1, col: 41 }, }; const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue .stringValue; @@ -169,6 +178,8 @@ describe('cellRequenstFromEvent()', () => { user9: 'u', revision: 0, id: '1358', + timeWarning: 0, + timeDanger: 0, }; const metadata = { type: { row: 1, col: 14 }, @@ -197,6 +208,8 @@ describe('cellRequenstFromEvent()', () => { user9: { row: 1, col: 37 }, revision: { row: 1, col: 38 }, id: { row: 1, col: 39 }, + timeWarning: { row: 1, col: 40 }, + timeDanger: { row: 1, col: 41 }, }; const result = cellRequestFromEvent(event, 1, 1234, metadata); expect(result.updateCells.rows[0].values[11].userEnteredValue.stringValue).toStrictEqual('x'); @@ -231,6 +244,8 @@ describe('cellRequenstFromEvent()', () => { user9: 'u', revision: 0, id: '1358', + timeWarning: 0, + timeDanger: 0, }; const metadata = { cue: { row: 1, col: 0 }, @@ -272,6 +287,8 @@ describe('cellRequenstFromEvent()', () => { user9: 'u', revision: 0, id: '1358', + timeWarning: 0, + timeDanger: 0, }; const metadata = { cue: { row: 1, col: 5 }, @@ -313,6 +330,8 @@ describe('cellRequenstFromEvent()', () => { user9: 'u', revision: 0, id: '1358', + timeWarning: 0, + timeDanger: 0, }; const metadata = { cue: { row: 10, col: 5 }, diff --git a/e2e/tests/000-smoke-tests.spec.ts b/e2e/tests/000-smoke-tests.spec.ts index c75cd006b..409af576d 100644 --- a/e2e/tests/000-smoke-tests.spec.ts +++ b/e2e/tests/000-smoke-tests.spec.ts @@ -8,7 +8,8 @@ test.describe('pages routes are available', () => { await page.goto('http://localhost:4001/editor'); await expect(page).toHaveTitle(/ontime/); - await expect(page.getByTestId('editor-container')).toBeVisible(); + // TODO (v3): not ready yet + // await expect(page.getByTestId('editor-container')).toBeVisible(); await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-timer-control')).toBeVisible(); await expect(page.getByTestId('panel-messages-control')).toBeVisible(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcc9ac1ff..be5b17fda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,12 @@ importers: passport-local: specifier: ~1.0.0 version: 1.0.0 + steno: + specifier: ^3.1.0 + version: 3.2.0 + ts-essentials: + specifier: ^9.4.1 + version: 9.4.1(typescript@5.2.2) ws: specifier: ^8.13.0 version: 8.13.0 @@ -8341,6 +8347,11 @@ packages: resolution: {integrity: sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==} dev: true + /steno@3.2.0: + resolution: {integrity: sha512-zPKkv+LqoYffxrtD0GIVA08DvF6v1dW02qpP5XnERoobq9g3MKcTSBTi08gbGNFMNRo3TQV/6kBw811T1LUhKg==} + engines: {node: '>=16'} + dev: false + /steno@4.0.2: resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} engines: {node: '>=18'} @@ -8625,6 +8636,17 @@ packages: typescript: 5.2.2 dev: true + /ts-essentials@9.4.1(typescript@5.2.2): + resolution: {integrity: sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==} + peerDependencies: + typescript: '>=4.1.0' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.2.2 + dev: false + /ts-node@10.9.1(@types/node@18.11.18)(typescript@5.2.2): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true @@ -8824,7 +8846,6 @@ packages: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true - dev: true /ufo@1.3.2: resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==}