refactor: flatten state mutations (#750)

This commit is contained in:
Carlos Valente
2024-01-31 14:02:27 +01:00
committed by GitHub
parent ec8952be56
commit 7eb9ee8d03
11 changed files with 720 additions and 637 deletions
@@ -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;
+3 -2
View File
@@ -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));
+4
View File
@@ -14,3 +14,7 @@ export const config = {
},
restoreFile: 'ontime.restore',
};
export const timerConfig = {
timeSkipLimit: 1000,
};
+83 -24
View File
@@ -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;
}
@@ -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,
@@ -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);
}
/**
@@ -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)}`);
}
}
}
+9 -9
View File
@@ -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;
-541
View File
@@ -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<TimerState>) {
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<TimerState>) {
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<R>(
/**
* 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<TState>) => 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<TState>);
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;
}
@@ -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', () => {});
});
});
+415
View File
@@ -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<RuntimeState> {
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<TimerState>) {
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<TimerState>) {
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;
}