diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx index 07ca16eae..37f55f871 100644 --- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx +++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx @@ -34,7 +34,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { const noEvents = numEvents === 0; const disableGo = isRolling || noEvents || (isLast && !isArmed); - const disablePrev = noEvents || isFirst; + const disablePrev = isRolling || noEvents || isFirst; const playbackCan = validatePlayback(playback); const disableStart = !playbackCan.start; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b4237a2bb..425bd6346 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -42,7 +42,7 @@ import { runtimeService } from './services/runtime-service/RuntimeService.js'; import { restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; import { populateDemo } from './modules/loadDemo.js'; -import { state, stateMutations } from './state.js'; +import { getState, updateNumEvents } from './stores/runtimeState.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -158,6 +158,7 @@ export const startServer = async () => { /** * Module initialises the services and provides initial payload for the store */ + const state = getState(); eventStore.init({ clock: state.clock, timer: state.timer, @@ -184,7 +185,7 @@ export const startServer = async () => { // TODO: do this on the init of the runtime service const numEvents = EventLoader.getNumEvents(); - stateMutations.updateNumEvents(numEvents); + updateNumEvents(numEvents); // eventStore set is a dependency of the services that publish to it messageService.init(eventStore.set.bind(eventStore)); diff --git a/apps/server/src/config/config.js b/apps/server/src/config/config.js index ac79ed456..9a137c8f4 100644 --- a/apps/server/src/config/config.js +++ b/apps/server/src/config/config.js @@ -14,3 +14,7 @@ export const config = { }, restoreFile: 'ontime.restore', }; + +export const timerConfig = { + timeSkipLimit: 1000, +}; diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index ef2bbc843..ff0af67d7 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,6 +1,11 @@ -import { OntimeEvent, Playback } from 'ontime-types'; +import { EndAction, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types'; -import { stateMutations, state } from '../state.js'; +import * as runtimeState from '../stores/runtimeState.js'; +import { integrationService } from './integration-service/IntegrationService.js'; +import { eventStore } from '../stores/EventStore.js'; +import { restoreService } from './RestoreService.js'; +import { runtimeService } from './runtime-service/RuntimeService.js'; +import { EventLoader } from '../classes/event-loader/EventLoader.js'; /** * Service manages Ontime's main timer @@ -21,51 +26,72 @@ export class TimerService { this._interval = setInterval(this.update, 32); } + @broadcastResult start() { - if (!state.eventNow) { - return; - } - - if (state.timer.playback === Playback.Play) { - return; - } - // TODO: when we start a timer, we schedule an update to its expected end - 16ms // we need to cancel this timer on pause, stop and addTime - stateMutations.timer.start(); + if (runtimeState.start()) { + integrationService.dispatch(TimerLifeCycle.onStart); + } } + @broadcastResult pause() { - if (state.timer.playback !== Playback.Play) { - return; + if (runtimeState.pause()) { + integrationService.dispatch(TimerLifeCycle.onPause); } - stateMutations.timer.pause(); } + @broadcastResult stop() { - if (state.timer.playback === Playback.Stop) { - return; + if (runtimeState.stop()) { + integrationService.dispatch(TimerLifeCycle.onStop); } - stateMutations.timer.stop(); } /** * Adds time to running timer by given amount * @param {number} amount */ - addTime(amount: number) { - if (state.timer.current === null) { - return; - } - stateMutations.timer.addTime(amount); + @broadcastResult + addTime(amount: number): boolean { + return runtimeState.addTime(amount); } /** * Update the app at regular intervals * @param {boolean} force whether we should force a broadcast of state */ + @broadcastResult update(force = false) { - stateMutations.timer.update(force, this._updateInterval); + const { didUpdate, doRoll, isFinished, shouldNotify } = runtimeState.update(force, this._updateInterval); + if (didUpdate && shouldNotify) { + // TODO: can we distinguish between a clock update and a timer update? + integrationService.dispatch(TimerLifeCycle.onUpdate); + } + + if (doRoll) { + const rundown = EventLoader.getPlayableEvents(); + runtimeState.roll(rundown); + } + + if (isFinished) { + integrationService.dispatch(TimerLifeCycle.onFinish); + const newState = runtimeState.getState(); + + // handle end action if there was a timer playing + if (newState.timer.playback === Playback.Play) { + if (newState.eventNow.endAction === EndAction.Stop) { + runtimeState.stop(); + } else if (newState.eventNow.endAction === EndAction.LoadNext) { + // we need to delay here to put this action in the queue stack. otherwise it won't be executed properly + setTimeout(runtimeState.loadNext, 0); + } else if (newState.eventNow.endAction === EndAction.PlayNext) { + // TODO: avoid calling the runtime service here + runtimeService.startNext(); + } + } + } } /** @@ -73,15 +99,48 @@ export class TimerService { * @throws {Error} if rundown is empty * @param {OntimeEvent[]} rundown -- list of events to run */ + @broadcastResult roll(rundown: OntimeEvent[]) { if (rundown.length === 0) { throw new Error('No events found'); } - stateMutations.timer.roll(rundown); + runtimeState.roll(rundown); } shutdown() { clearInterval(this._interval); } } + +function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = function (...args: any[]) { + const result = originalMethod.apply(this, args); + const state = runtimeState.getState(); + + // TODO: compare datasets to see what needs to be emitted + eventStore.batchSet({ + clock: state.clock, + eventNow: state.eventNow, + publicEventNow: state.publicEventNow, + eventNext: state.eventNext, + publicEventNext: state.publicEventNext, + runtime: state.runtime, + timer: state.timer, + }); + + // we write to restore service if the underlying data changes + restoreService.save({ + playback: state.timer.playback, + selectedEventId: state.eventNow?.id ?? null, + startedAt: state.timer.startedAt, + addedTime: state.timer.addedTime, + pausedAt: state._timer.pausedAt, + }); + return result; + }; + + return descriptor; +} diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 97fb7ffc9..ce363d602 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -9,7 +9,7 @@ import { skippedOutOfEvent, updateRoll, } from '../timerUtils.js'; -import { TState } from '../../state.js'; +import { RuntimeState } from '../../stores/runtimeState.js'; describe('getExpectedFinish()', () => { it('is null if we havent started', () => { @@ -27,7 +27,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(null); }); @@ -46,7 +46,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(20); }); @@ -65,7 +65,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(11); }); @@ -84,7 +84,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(31); @@ -104,7 +104,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(1); @@ -124,7 +124,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(1); @@ -144,7 +144,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(1); @@ -164,7 +164,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(10); @@ -185,7 +185,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(40); @@ -204,7 +204,7 @@ describe('getExpectedFinish()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const calculatedFinish = getExpectedFinish(state); // expected finish is not a duration but a point in time @@ -230,7 +230,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(111); @@ -251,7 +251,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(9); @@ -272,7 +272,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(19); @@ -293,7 +293,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(dayInMs + 10); @@ -314,7 +314,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(15); @@ -335,7 +335,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(35); @@ -358,7 +358,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(70); @@ -380,7 +380,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(70); @@ -401,7 +401,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(77); @@ -422,7 +422,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(600000 + dayInMs - 79500000); @@ -443,7 +443,7 @@ describe('getCurrent()', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const current = getCurrent(state); expect(current).toBe(600000 + dayInMs - 79500000); @@ -469,7 +469,7 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const expectedFinish = getExpectedFinish(state); const current = getCurrent(state); @@ -497,7 +497,7 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => { _timer: { pausedAt: null, }, - } as TState; + } as RuntimeState; const expectedFinish = getExpectedFinish(state); const current = getCurrent(state); @@ -522,11 +522,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock += testSkipLimit; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); @@ -543,11 +542,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock += testSkipLimit; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); @@ -563,11 +561,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock = testSkipLimit - 2; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); @@ -583,11 +580,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock -= testSkipLimit; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); @@ -604,11 +600,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock += testSkipLimit + 1; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); @@ -625,11 +620,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock -= testSkipLimit + 1; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); @@ -645,11 +639,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock = testSkipLimit - 2; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); @@ -665,11 +658,10 @@ describe('skippedOutOfEvent()', () => { expectedFinish, startedAt, }, - } as TState; + } as RuntimeState; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - // @ts-expect-error -- cheating in tests state.clock -= testSkipLimit + 1; expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); @@ -1205,7 +1197,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: null, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: 15 - 11, @@ -1217,9 +1209,7 @@ describe('updateRoll()', () => { expect(updateRoll(timers)).toStrictEqual(expected); // test that it can jump time - // @ts-expect-error -- cheating for tests timers.timer.expectedFinish = 1000; - // @ts-expect-error -- cheating for tests timers.clock = 600; expected.updatedTimer = 1000 - 600; @@ -1238,7 +1228,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: 15, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: null, @@ -1265,7 +1255,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: null, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: -1, @@ -1290,7 +1280,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: 15, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: null, @@ -1314,7 +1304,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: 15, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: null, @@ -1341,7 +1331,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: null, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: 20, @@ -1368,7 +1358,7 @@ describe('updateRoll()', () => { _timer: { secondaryTarget: null, }, - } as TState; + } as RuntimeState; const expected = { updatedTimer: dayInMs, diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index b92366239..30f8d9763 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -26,7 +26,7 @@ import { } from './rundownCache.js'; import { logger } from '../../classes/Logger.js'; import { createEvent } from '../../utils/parser.js'; -import { stateMutations } from '../../state.js'; +import { updateNumEvents } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; /** @@ -169,7 +169,7 @@ export async function swapEvents(from: string, to: string) { */ function updateChangeNumEvents() { const numEvents = EventLoader.getPlayableEvents().length; - stateMutations.updateNumEvents(numEvents); + updateNumEvents(numEvents); } /** diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 35d575c5f..aa55b48c7 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -5,7 +5,7 @@ import { EventLoader } from '../../classes/event-loader/EventLoader.js'; import { TimerService } from '../TimerService.js'; import { logger } from '../../classes/Logger.js'; import { RestorePoint } from '../RestoreService.js'; -import { state, stateMutations } from '../../state.js'; +import * as runtimeState from '../../stores/runtimeState.js'; /** * Service manages runtime status of app @@ -38,6 +38,7 @@ class RuntimeService { * Checks if a list of IDs is in the current selection */ private affectsLoaded(affectedIds: string[]): boolean { + const state = runtimeState.getState(); const now = state.eventNow?.id; const nowPublic = state.publicEventNow?.id; const next = state.eventNext?.id; @@ -52,6 +53,7 @@ class RuntimeService { private isNewNext() { const timedEvents = EventLoader.getPlayableEvents(); + const state = runtimeState.getState(); const now = state.eventNow?.id; const next = state.eventNext?.id; @@ -88,13 +90,14 @@ class RuntimeService { } reset() { - stateMutations.timer.clear(); + runtimeState.clear(); } /** * check whether underlying data of runtime has changed */ update(affectedIds?: string[]) { + const state = runtimeState.getState(); const hasLoadedElements = state.eventNow || state.eventNext; if (!hasLoadedElements) { return; @@ -115,7 +118,7 @@ class RuntimeService { // load stuff again, but keep running if our events still exist const eventNow = EventLoader.getEventWithId(state.eventNow.id); if (eventNow) { - stateMutations.reload(eventNow); + runtimeState.reload(eventNow); } return; } @@ -124,7 +127,7 @@ class RuntimeService { if (isNext) { // TODO: do i need to load here? const playableEvents = EventLoader.getPlayableEvents(); - stateMutations.loadNext(playableEvents); + runtimeState.loadNext(playableEvents); } } @@ -140,7 +143,9 @@ class RuntimeService { } const timedEvents = EventLoader.getPlayableEvents(); - stateMutations.load(event, timedEvents); + const state = runtimeState.getState(); + // TODO: return success boolean from runtimeState + runtimeState.load(event, timedEvents); const success = event.id === state.eventNow?.id; if (success) { @@ -229,6 +234,7 @@ class RuntimeService { * @return {boolean} success - whether an event was loaded */ loadPrevious(): boolean { + const state = runtimeState.getState(); const previousEvent = EventLoader.findPrevious(state.eventNow?.id); if (previousEvent) { const success = this.loadEvent(previousEvent); @@ -242,6 +248,7 @@ class RuntimeService { * @return {boolean} success */ loadNext(): boolean { + const state = runtimeState.getState(); const nextEvent = EventLoader.findNext(state.eventNow?.id); if (nextEvent) { const success = this.loadEvent(nextEvent); @@ -256,6 +263,7 @@ class RuntimeService { * Starts playback on selected event */ start() { + const state = runtimeState.getState(); const canStart = validatePlayback(state.timer.playback).start; if (canStart) { this.eventTimer.start(); @@ -277,6 +285,7 @@ class RuntimeService { * Pauses playback on selected event */ pause() { + const state = runtimeState.getState(); if (validatePlayback(state.timer.playback).pause) { this.eventTimer.pause(); const newState = state.timer.playback; @@ -288,6 +297,7 @@ class RuntimeService { * Stops timer and unloads any events */ stop() { + const state = runtimeState.getState(); if (validatePlayback(state.timer.playback).stop) { this.eventTimer.stop(); const newState = state.timer.playback; @@ -299,8 +309,9 @@ class RuntimeService { * Reloads current event */ reload() { + const state = runtimeState.getState(); if (state.eventNow) { - stateMutations.reload(); + runtimeState.reload(); } } @@ -315,6 +326,7 @@ class RuntimeService { logger.warning(LogOrigin.Server, `Roll: ${error}`); } + const state = runtimeState.getState(); const newState = state.timer.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); } @@ -337,7 +349,7 @@ class RuntimeService { } const timedEvents = EventLoader.getPlayableEvents(); - stateMutations.resume(restorePoint, event, timedEvents); + runtimeState.resume(restorePoint, event, timedEvents); logger.info(LogOrigin.Playback, 'Resuming playback'); } @@ -346,8 +358,9 @@ class RuntimeService { * @param {number} time - time to add in milliseconds */ addTime(time: number) { - this.eventTimer.addTime(time); - logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`); + if (this.eventTimer.addTime(time)) { + logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`); + } } } diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index f9c801c3c..b80e0027f 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,6 +1,6 @@ import { MaybeNumber, OntimeEvent, TimerType } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; -import { TState } from '../state.js'; +import { RuntimeState } from '../stores/runtimeState.js'; import { sortArrayByProperty } from '../utils/arrayUtils.js'; /** @@ -10,10 +10,10 @@ export const normaliseEndTime = (start: number, end: number) => (end < start ? e /** * Calculates expected finish time of a running timer - * @param {TState} state runtime state + * @param {RuntimeState} state runtime state * @returns {number | null} new current time or null if nothing is running */ -export function getExpectedFinish(state: TState): MaybeNumber { +export function getExpectedFinish(state: RuntimeState): MaybeNumber { const { startedAt, finishedAt, duration, addedTime } = state.timer; const { timerType, timeEnd } = state.eventNow; const { pausedAt } = state._timer; @@ -45,10 +45,10 @@ export function getExpectedFinish(state: TState): MaybeNumber { /** * Calculates running countdown - * @param {TState} state runtime state + * @param {RuntimeState} state runtime state * @returns {number} current time for timer */ -export function getCurrent(state: TState): number { +export function getCurrent(state: RuntimeState): number { const { startedAt, duration, addedTime } = state.timer; const { timerType, timeEnd } = state.eventNow; const { pausedAt } = state._timer; @@ -75,12 +75,12 @@ export function getCurrent(state: TState): number { /** * Checks whether we have skipped out of the event - * @param {TState} state runtime state + * @param {RuntimeState} state runtime state * @param {number} previousTime previous clock * @param {number} skipLimit how much time can we skip * @returns {boolean} */ -export function skippedOutOfEvent(state: TState, previousTime: number, skipLimit: number): boolean { +export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean { const { startedAt, expectedFinish } = state.timer; const { clock } = state; @@ -226,10 +226,10 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => { /** * @description Implements update functions for roll mode - * @param {TState} + * @param {RuntimeState} * @returns object with selection variables */ -export const updateRoll = (state: TState) => { +export const updateRoll = (state: RuntimeState) => { const { current, expectedFinish, startedAt, secondaryTimer } = state.timer; const { secondaryTarget } = state._timer; const { clock } = state; diff --git a/apps/server/src/state.ts b/apps/server/src/state.ts deleted file mode 100644 index b9df0bf57..000000000 --- a/apps/server/src/state.ts +++ /dev/null @@ -1,541 +0,0 @@ -import type { DeepReadonly, DeepWritable } from 'ts-essentials'; - -import { - EndAction, - Runtime, - OntimeEvent, - Playback, - TimerLifeCycle, - TimerState, - TimerType, - MaybeNumber, -} 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, getRollTimers, skippedOutOfEvent, updateRoll } from './services/timerUtils.js'; -import { eventStore } from './stores/EventStore.js'; -import { integrationService } from './services/integration-service/IntegrationService.js'; -import { runtimeService } from './services/runtime-service/RuntimeService.js'; -import { EventLoader } from './classes/event-loader/EventLoader.js'; - -// TODO: move to timer config -const timeSkipLimit = 1000; - -const initialRuntime: Runtime = { - selectedEventIndex: null, - numEvents: 0, -}; - -const initialTimer: TimerState = { - addedTime: 0, - current: null, - duration: null, - elapsed: null, - expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients - finishedAt: null, - playback: Playback.Stop, - secondaryTimer: null, - startedAt: null, -}; - -export type TState = DeepReadonly<{ - clock: number; // realtime clock - eventNow: OntimeEvent | null; - publicEventNow: OntimeEvent | null; - eventNext: OntimeEvent | null; - publicEventNext: OntimeEvent | null; - runtime: Runtime; - timer: TimerState; - // private properties of the timer calculations - _timer: { - pausedAt: MaybeNumber; - finishedNow: boolean; - lastUpdate: MaybeNumber; - secondaryTarget: MaybeNumber; - }; -}>; - -export const state: TState = { - clock: clock.timeNow(), - eventNow: null, - publicEventNow: null, - eventNext: null, - publicEventNext: null, - runtime: initialRuntime, - timer: { ...initialTimer }, - _timer: { - pausedAt: null, - lastUpdate: null, - secondaryTarget: null, - get finishedNow() { - return this.current <= 0 && this.finishedAt === null; - }, - }, -}; - -export const stateMutations = { - load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial) { - mutate((state) => { - stateMutations.timer.clear(); - - const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id); - - state.runtime.selectedEventIndex = eventIndex; - state.runtime.numEvents = rundown.length; - - this.loadNow(event, rundown); - this.loadNext(rundown); - - state.clock = clock.timeNow(); - state.timer.playback = Playback.Armed; - state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); - state.timer.current = getCurrent(state); - - if (initialData) { - stateMutations.timer.patch(initialData); - } - }); - }, - loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) { - mutate((state) => { - state.eventNow = event; - - // check if current is also public - if (event.isPublic) { - state.publicEventNow = event; - } else { - // assume there is no public event - state.publicEventNow = null; - - // if there is nothing before, return - if (!state.runtime.selectedEventIndex) { - return; - } - - // iterate backwards to find it - for (let i = state.runtime.selectedEventIndex; i >= 0; i--) { - if (playableEvents[i].isPublic) { - state.publicEventNow = playableEvents[i]; - break; - } - } - } - }); - }, - loadNext(playableEvents: OntimeEvent[]) { - mutate((state) => { - // assume there are no next events - state.eventNext = null; - state.publicEventNext = null; - - if (state.runtime.selectedEventIndex === null) { - return; - } - - const numEvents = playableEvents.length; - - if (state.runtime.selectedEventIndex < numEvents - 1) { - let nextPublic = false; - let nextProduction = false; - - for (let i = state.runtime.selectedEventIndex + 1; i < numEvents; i++) { - // if we have not set private - if (!nextProduction) { - state.eventNext = playableEvents[i]; - nextProduction = true; - } - - // if event is public - if (playableEvents[i].isPublic) { - state.publicEventNext = playableEvents[i]; - nextPublic = true; - } - - // Stop if both are set - if (nextPublic && nextProduction) break; - } - } - }); - }, - resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeEvent[]) { - mutate((_state) => { - stateMutations.load(event, rundown, restorePoint); - }); - }, - /** - * We only pass an event if we are hot reloading - * @param {OntimeEvent} event only passed if we are changing the data if a playing timer - */ - reload(event?: OntimeEvent) { - mutate((state) => { - if (event) { - state.eventNow = event; - - // update data which is duplicate between eventNow and timer objects - state.timer.duration = calculateDuration(state.eventNow.timeStart, state.eventNow.timeEnd); - state.timer.current = getCurrent(state); - state.timer.expectedFinish = getExpectedFinish(state); - return; - } - state.timer.playback = Playback.Armed; - - state.timer.duration = calculateDuration(state.eventNow.timeStart, state.eventNow.timeEnd); - state.timer.current = state.timer.duration; - state.timer.elapsed = null; - - state.timer.startedAt = null; - state.timer.finishedAt = null; - state._timer.pausedAt = null; - state.timer.addedTime = 0; - - state.timer.expectedFinish = getExpectedFinish(state); - }); - }, - updateNumEvents(numEvents: number) { - mutate((state) => { - state.runtime.numEvents = numEvents; - }); - }, - timer: { - // utility to reset the state of the timer - // TODO: create smaller utilities to reset parts of the state - clear() { - mutate((state) => { - // TODO: check that entire state is reset here - state.eventNow = null; - state.publicEventNow = null; - state.eventNext = null; - state.publicEventNext = null; - - state.runtime = { ...initialRuntime }; - // TODO: could we avoid having this dependency? - state.runtime.numEvents = EventLoader.getPlayableEvents().length; - - state.timer.playback = Playback.Stop; - state.clock = clock.timeNow(); - state.timer = { ...initialTimer }; - state._timer = { - pausedAt: null, - lastUpdate: null, - secondaryTarget: null, - finishedNow: false, - }; - }); - }, - /** 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) => { - state.clock = clock.timeNow(); - state.timer.secondaryTimer = null; - state._timer.secondaryTarget = null; - - // add paused time if it exists - if (state._timer.pausedAt) { - const timeToAdd = state.clock - state._timer.pausedAt; - state.timer.addedTime += timeToAdd; - state._timer.pausedAt = null; - } - - if (state.timer.startedAt === null) { - state.timer.startedAt = state.clock; - } - - state.timer.playback = Playback.Play; - state.timer.expectedFinish = getExpectedFinish(state); - }, - { - sideEffect() { - integrationService.dispatch(TimerLifeCycle.onStart); - }, - }, - ); - }, - stop() { - mutate( - (_state) => { - stateMutations.timer.clear(); - }, - { - sideEffect() { - integrationService.dispatch(TimerLifeCycle.onStop); - }, - }, - ); - }, - pause() { - mutate( - (state) => { - if (state.timer.playback !== Playback.Play) { - return false; - } - - state.timer.playback = Playback.Pause; - state.clock = clock.timeNow(); - state._timer.pausedAt = state.clock; - return true; - }, - { - sideEffect(hasChanged: boolean) { - if (hasChanged) { - integrationService.dispatch(TimerLifeCycle.onPause); - } - }, - }, - ); - }, - resume(event: OntimeEvent, restorePoint: RestorePoint) { - mutate((state) => { - state.clock = clock.timeNow(); - - state.timer.startedAt = restorePoint.startedAt; - state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); - state.timer.current = state.timer.duration; - - state.timer.playback = restorePoint.playback; - state._timer.pausedAt = restorePoint.pausedAt; - state.timer.addedTime = restorePoint.addedTime; - - // check if event finished meanwhile - if (event.timerType === TimerType.TimeToEnd) { - state.timer.current = getCurrent(state); - } - }); - }, - addTime(amount: number) { - mutate((state) => { - // TODO: what kind of validation go here or in the consumer? - state.timer.addedTime += amount; - state.timer.expectedFinish += amount; - state.timer.current += amount; - - // handle edge cases - const willGoNegative = amount < 0 && Math.abs(amount) > state.timer.current; - const hasFinished = state.timer.finishedAt !== null; - if (willGoNegative && !hasFinished) { - 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 ??? maybe we can remove the options altogether? - // TODO: should we have a semaphore to stop update while other things are running? - update(force: boolean, updateInterval: number) { - return mutate( - (state) => { - function roll() { - const hasSkippedOutOfEvent = skippedOutOfEvent(state, previousTime, timeSkipLimit); - if (hasSkippedOutOfEvent) { - return { doRoll: true }; - } - const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(state); - state.timer.current = updatedTimer; - state.timer.secondaryTimer = updatedSecondaryTimer; - state.timer.elapsed = state.timer.duration - state.timer.current; - - return { doRoll: doRollLoad, isFinished }; - } - - function play() { - let isFinished = false; - state.timer.current = getCurrent(state); - - if (state.timer.playback === Playback.Play && state._timer.finishedNow) { - state.timer.finishedAt = state.clock; - isFinished = true; - } else { - state.timer.expectedFinish = getExpectedFinish(state); - } - - state.timer.elapsed = state.timer.duration - state.timer.current; - - return { isFinished }; - } - - // TODO: should this logic be moved up? - // 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.clock; - state.clock = clock.timeNow(); - const hasSkippedBack = previousTime > state.clock; - - if (hasSkippedBack) { - _force = true; - } - - // we call integrations if we update timers - if (state.timer.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; - } else if (state.eventNow?.timerType === TimerType.TimeToEnd) { - // or if we are in a time-to-end timer - state.timer.current = getCurrent(state); - state.timer.duration = state.timer.current; - } - - // we only update the store at the updateInterval - // side effects such as onFinish will still be triggered in the update functions - const isTimeToUpdate = state.clock > state._timer.lastUpdate + updateInterval; - if (_force || isTimeToUpdate) { - state._timer.lastUpdate = state.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) { - runtimeService.roll(); - } - - if (isFinished) { - integrationService.dispatch(TimerLifeCycle.onFinish); - - // handle end action if there was a timer playing - if (newState.timer.playback === Playback.Play) { - if (newState.eventNow.endAction === EndAction.Stop) { - runtimeService.stop(); - } else if (newState.eventNow.endAction === EndAction.LoadNext) { - // we need to delay here to put this action in the queue stack. otherwise it won't be executed properly - setTimeout(runtimeService.loadNext, 0); - } else if (newState.eventNow.endAction === EndAction.PlayNext) { - runtimeService.startNext(); - } - } - } - }, - }, - ); - }, - roll(rundown: OntimeEvent[]) { - mutate((state) => { - stateMutations.timer.clear(); - - const { nextEvent, currentEvent } = getRollTimers(rundown, state.clock); - - 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 - stateMutations.load(currentEvent, rundown, { - startedAt: currentEvent.timeStart, - expectedFinish: currentEvent.timeEnd, - current: endTime - state.clock, - }); - } else if (nextEvent) { - // account for day after - const nextStart = nextEvent.timeStart < state.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; - // nothing now, but something coming up - state.timer.secondaryTimer = nextStart - state.clock; - state._timer.secondaryTarget = nextStart; - } - state.timer.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. - */ -export 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({ - clock: newState.clock, - eventNow: newState.eventNow, - publicEventNow: newState.publicEventNow, - eventNext: newState.eventNext, - publicEventNext: newState.publicEventNext, - runtime: newState.runtime, - timer: newState.timer, - }); - - // we write to restore service if the underlying data changes - restoreService.save({ - playback: state.timer.playback, - selectedEventId: state.eventNow?.id ?? null, - startedAt: state.timer.startedAt, - addedTime: state.timer.addedTime, - pausedAt: state._timer.pausedAt, - }); - - return result; -} diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts new file mode 100644 index 000000000..0cd4a5236 --- /dev/null +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -0,0 +1,142 @@ +import { OntimeEvent, Playback } from 'ontime-types'; +import { deepmerge } from 'ontime-utils'; + +import { RuntimeState, clear, getState, load, pause, start, stop } from '../runtimeState.js'; + +const mockEvent = { + id: 'mock', + cue: 'mock', + timeStart: 0, + timeEnd: 1000, + duration: 1000, +} as OntimeEvent; + +const mockState = { + clock: 666, + eventNow: null, + publicEventNow: null, + eventNext: null, + publicEventNext: null, + runtime: { + selectedEventIndex: null, + numEvents: 0, + }, + timer: { + addedTime: 0, + current: null, + duration: null, + elapsed: null, + expectedFinish: null, + finishedAt: null, + playback: Playback.Stop, + secondaryTimer: null, + startedAt: null, + }, + _timer: { + pausedAt: null, + lastUpdate: null, + secondaryTarget: null, + }, +} as RuntimeState; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const makeMockState = (patch: RuntimeState): RuntimeState => { + return deepmerge(mockState, patch); +}; + +describe('mutation on runtimeState', () => { + beforeEach(() => { + clear(); + + vi.mock('../../classes/event-loader/EventLoader.js', () => ({ + EventLoader: { + getPlayableEvents: vi.fn().mockReturnValue([ + { + id: 'mock', + cue: 'mock', + timeStart: 0, + timeEnd: 1000, + duration: 1000, + }, + ]), + }, + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('playback operations', () => { + it('refuses if nothing is loaded', () => { + let success = start(mockState); + expect(success).toBe(false); + + success = pause(); + expect(success).toBe(false); + }); + test('normal playback cycle', () => { + // 1. Load event + load(mockEvent, [mockEvent]); + let newState = getState(); + expect(newState.eventNow.id).toBe(mockEvent.id); + expect(newState.timer.playback).toBe(Playback.Armed); + expect(newState.clock).not.toBe(666); + + // 2. Start event + let success = start(); + newState = getState(); + expect(success).toBe(true); + expect(newState.timer).toMatchObject({ + playback: Playback.Play, + }); + + // 3. Pause event + success = pause(); + newState = getState(); + expect(success).toBe(true); + expect(newState.clock).not.toBe(666); + expect(newState.timer).toMatchObject({ + playback: Playback.Pause, + addedTime: 0, + }); + expect(newState._timer.pausedAt).toEqual(newState.clock); + + success = pause(); + expect(success).toBe(false); + + // 4. Restart event + success = start(); + newState = getState(); + expect(success).toBe(true); + expect(newState.timer).toMatchObject({ + playback: Playback.Play, + secondaryTimer: null, + }); + expect(newState.timer).toEqual( + expect.objectContaining({ + current: expect.any(Number), + duration: expect.any(Number), + elapsed: expect.any(Number), + expectedFinish: expect.any(Number), + startedAt: expect.any(Number), + }), + ); + expect(newState._timer.pausedAt).toBeNull(); + + // 4. Stop event + success = stop(); + expect(success).toBe(true); + expect(newState.eventNow).toBe(null); + expect(newState.timer).toMatchObject({ + playback: Playback.Stop, + duration: null, + elapsed: null, + expectedFinish: null, + startedAt: null, + }); + }); + + test.todo('roll mode', () => {}); + }); +}); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts new file mode 100644 index 000000000..f1ce6434a --- /dev/null +++ b/apps/server/src/stores/runtimeState.ts @@ -0,0 +1,415 @@ +import { Runtime, OntimeEvent, Playback, TimerState, TimerType, MaybeNumber } from 'ontime-types'; +import { calculateDuration, dayInMs } from 'ontime-utils'; + +import { clock } from '../services/Clock.js'; +import { RestorePoint } from '../services/RestoreService.js'; +import { getCurrent, getExpectedFinish, getRollTimers, skippedOutOfEvent, updateRoll } from '../services/timerUtils.js'; +import { EventLoader } from '../classes/event-loader/EventLoader.js'; +import { timerConfig } from '../config/config.js'; + +const initialRuntime: Runtime = { + selectedEventIndex: null, + numEvents: 0, +}; + +const initialTimer: TimerState = { + addedTime: 0, + current: null, + duration: null, + elapsed: null, + expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients + finishedAt: null, + playback: Playback.Stop, + secondaryTimer: null, + startedAt: null, +}; + +export type RuntimeState = { + clock: number; // realtime clock + eventNow: OntimeEvent | null; + publicEventNow: OntimeEvent | null; + eventNext: OntimeEvent | null; + publicEventNext: OntimeEvent | null; + runtime: Runtime; + timer: TimerState; + // private properties of the timer calculations + _timer: { + pausedAt: MaybeNumber; + finishedNow: boolean; + lastUpdate: MaybeNumber; + secondaryTarget: MaybeNumber; + }; +}; + +const runtimeState: RuntimeState = { + clock: clock.timeNow(), + eventNow: null, + publicEventNow: null, + eventNext: null, + publicEventNext: null, + runtime: initialRuntime, + timer: { ...initialTimer }, + _timer: { + pausedAt: null, + lastUpdate: null, + secondaryTarget: null, + get finishedNow() { + return this.current <= 0 && this.finishedAt === null; + }, + }, +}; + +export function getState(): Readonly { + return runtimeState; +} + +export function clear() { + // TODO: check that entire state is reset here + runtimeState.eventNow = null; + runtimeState.publicEventNow = null; + runtimeState.eventNext = null; + runtimeState.publicEventNext = null; + + runtimeState.runtime = { ...initialRuntime }; + runtimeState.runtime.numEvents = fetchNumEvents(); + + runtimeState.timer.playback = Playback.Stop; + runtimeState.clock = clock.timeNow(); + runtimeState.timer = { ...initialTimer }; + runtimeState._timer = { + pausedAt: null, + lastUpdate: null, + secondaryTarget: null, + finishedNow: false, + }; +} + +/** + * Utility to allow modifying the state from the outside + * @param newState + */ +function patchTimer(newState: Partial) { + for (const key in newState) { + if (key in runtimeState.timer) { + runtimeState.timer[key] = newState[key]; + } + } +} + +/** + * Utility, getches number of events from EventLoader + * @param numEvents + */ +function fetchNumEvents(): number { + // TODO: could we avoid having this dependency? + return EventLoader.getPlayableEvents().length; +} + +/** + * Utility, allows updating the number of events + * @param numEvents + */ +export function updateNumEvents(numEvents: number) { + runtimeState.runtime.numEvents = numEvents; +} + +/** + * Loads a given event into state + * @param event + * @param rundown + * @param initialData + */ +export function load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial) { + clear(); + + const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id); + + runtimeState.runtime.selectedEventIndex = eventIndex; + runtimeState.runtime.numEvents = rundown.length; + + loadNow(event, rundown); + loadNext(rundown); + + runtimeState.clock = clock.timeNow(); + runtimeState.timer.playback = Playback.Armed; + runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd); + runtimeState.timer.current = getCurrent(runtimeState); + + if (initialData) { + patchTimer(initialData); + } +} + +export function loadNow(event: OntimeEvent, playableEvents: OntimeEvent[]) { + runtimeState.eventNow = event; + + // check if current is also public + if (event.isPublic) { + runtimeState.publicEventNow = event; + } else { + // assume there is no public event + runtimeState.publicEventNow = null; + + // if there is nothing before, return + if (!runtimeState.runtime.selectedEventIndex) { + return; + } + + // iterate backwards to find it + for (let i = runtimeState.runtime.selectedEventIndex; i >= 0; i--) { + if (playableEvents[i].isPublic) { + runtimeState.publicEventNow = playableEvents[i]; + break; + } + } + } +} + +export function loadNext(playableEvents: OntimeEvent[]) { + // assume there are no next events + runtimeState.eventNext = null; + runtimeState.publicEventNext = null; + + if (runtimeState.runtime.selectedEventIndex === null) { + return; + } + + const numEvents = playableEvents.length; + + if (runtimeState.runtime.selectedEventIndex < numEvents - 1) { + let nextPublic = false; + let nextProduction = false; + + for (let i = runtimeState.runtime.selectedEventIndex + 1; i < numEvents; i++) { + // if we have not set private + if (!nextProduction) { + runtimeState.eventNext = playableEvents[i]; + nextProduction = true; + } + + // if event is public + if (playableEvents[i].isPublic) { + runtimeState.publicEventNext = playableEvents[i]; + nextPublic = true; + } + + // Stop if both are set + if (nextPublic && nextProduction) break; + } + } +} + +export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeEvent[]) { + load(event, rundown, restorePoint); +} + +/** + * We only pass an event if we are hot reloading + * @param {OntimeEvent} event only passed if we are changing the data if a playing timer + */ +export function reload(event?: OntimeEvent) { + if (event) { + runtimeState.eventNow = event; + + // update data which is duplicate between eventNow and timer objects + runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); + runtimeState.timer.current = getCurrent(runtimeState); + runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + return; + } + runtimeState.timer.playback = Playback.Armed; + + runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); + runtimeState.timer.current = runtimeState.timer.duration; + runtimeState.timer.elapsed = null; + + runtimeState.timer.startedAt = null; + runtimeState.timer.finishedAt = null; + runtimeState._timer.pausedAt = null; + runtimeState.timer.addedTime = 0; + + runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); +} + +export function start(state: RuntimeState = runtimeState): boolean { + if (state.eventNow === null) { + return false; + } + if (state.timer.playback === Playback.Play) { + return false; + } + state.clock = clock.timeNow(); + state.timer.secondaryTimer = null; + state._timer.secondaryTarget = null; + + // add paused time if it exists + if (state._timer.pausedAt) { + const timeToAdd = state.clock - state._timer.pausedAt; + state.timer.addedTime += timeToAdd; + state._timer.pausedAt = null; + } + + if (state.timer.startedAt === null) { + state.timer.startedAt = state.clock; + } + + state.timer.playback = Playback.Play; + state.timer.expectedFinish = getExpectedFinish(state); + state.timer.elapsed = 0; + return true; +} + +export function pause(state: RuntimeState = runtimeState): boolean { + if (state.timer.playback !== Playback.Play) { + return false; + } + + state.timer.playback = Playback.Pause; + state.clock = clock.timeNow(); + state._timer.pausedAt = state.clock; + return true; +} + +export function stop(state: RuntimeState = runtimeState): boolean { + if (state.timer.playback === Playback.Stop) { + return false; + } + clear(); + return true; +} + +export function addTime(amount: number) { + if (runtimeState.timer.current === null) { + return false; + } + + runtimeState.timer.addedTime += amount; + runtimeState.timer.expectedFinish += amount; + runtimeState.timer.current += amount; + + // handle edge cases + const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current; + const hasFinished = runtimeState.timer.finishedAt !== null; + if (willGoNegative && !hasFinished) { + runtimeState.timer.finishedAt = clock.timeNow(); + } else { + const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; + if (willGoPositive) { + runtimeState.timer.finishedAt = null; + } + } + return true; +} + +export function update(force: boolean, updateInterval: number) { + // TODO: should this logic be moved to consumer? + // 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 = runtimeState.clock; + runtimeState.clock = clock.timeNow(); + const hasSkippedBack = previousTime > runtimeState.clock; + + if (hasSkippedBack) { + _force = true; + } + + // we call integrations if we update timers + if (runtimeState.timer.playback === Playback.Roll) { + const result = roll(); + _shouldNotify = true; + _doRoll = result.doRoll; + _isFinished = result.isFinished; + } else if (runtimeState.timer.startedAt !== null) { + // we only update timer if a timer has been started + const result = play(); + _shouldNotify = true; + _isFinished = result.isFinished; + } else if (runtimeState.eventNow?.timerType === TimerType.TimeToEnd) { + // or if we are in a time-to-end timer + runtimeState.timer.current = getCurrent(runtimeState); + runtimeState.timer.duration = runtimeState.timer.current; + } + + // we only update the store at the updateInterval + // side effects such as onFinish will still be triggered in the update functions + const isTimeToUpdate = runtimeState.clock > runtimeState._timer.lastUpdate + updateInterval; + if (_force || isTimeToUpdate) { + runtimeState._timer.lastUpdate = runtimeState.clock; + // TODO: can we simplify the didUpdate and shouldNotify + _didUpdate = true; + } + + return { + didUpdate: _didUpdate, + doRoll: _doRoll, + isFinished: _isFinished, + shouldNotify: _shouldNotify, + }; + + function roll() { + const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.timeSkipLimit); + if (hasSkippedOutOfEvent) { + return { doRoll: true }; + } + const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(runtimeState); + runtimeState.timer.current = updatedTimer; + runtimeState.timer.secondaryTimer = updatedSecondaryTimer; + runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; + + return { doRoll: doRollLoad, isFinished }; + } + + function play() { + let isFinished = false; + runtimeState.timer.current = getCurrent(runtimeState); + + if (runtimeState.timer.playback === Playback.Play && runtimeState._timer.finishedNow) { + runtimeState.timer.finishedAt = runtimeState.clock; + isFinished = true; + } else { + runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + } + + runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; + + return { isFinished }; + } +} + +export function roll(rundown: OntimeEvent[]) { + clear(); + + runtimeState.runtime.numEvents = rundown.length; + const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock); + + if (currentEvent) { + // there is something running, load + runtimeState.timer.secondaryTimer = null; + runtimeState._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 + load(currentEvent, rundown, { + startedAt: currentEvent.timeStart, + expectedFinish: currentEvent.timeEnd, + current: endTime - runtimeState.clock, + }); + } else if (nextEvent) { + // account for day after + const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; + // nothing now, but something coming up + runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock; + runtimeState._timer.secondaryTarget = nextStart; + } + + runtimeState.timer.playback = Playback.Roll; +}