diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 115b93546..dc6455e00 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -4,6 +4,8 @@ import * as runtimeState from '../stores/runtimeState.js'; import type { UpdateResult } from '../stores/runtimeState.js'; import { timerConfig } from '../config/config.js'; +type UpdateCallbackFn = (updateResult: UpdateResult) => void; + /** * Service manages Ontime's main timer */ @@ -13,9 +15,9 @@ export class TimerService { static _refreshInterval: number; /** when timer will be finished */ - private endCallback: NodeJS.Timeout; + private endCallback: NodeJS.Timeout | undefined = undefined; - private onUpdateCallback: (updateResult: UpdateResult) => void; + private onUpdateCallback: UpdateCallbackFn | undefined = undefined; /** * @constructor @@ -23,19 +25,21 @@ export class TimerService { * @param {number} [timerConfig.updateInterval] how often we update the socket * @param {function} [timerConfig.onUpdateCallback] how often we update the socket */ - constructor(timerConfig: { - refresh: number; - updateInterval: number; - onUpdateCallback: (updateResult: UpdateResult) => void; - }) { + constructor(timerConfig: { refresh: number; updateInterval: number }) { TimerService._refreshInterval = timerConfig.refresh; - - this.onUpdateCallback = timerConfig.onUpdateCallback; this._interval = setInterval(() => { this.update(); }, TimerService._refreshInterval); } + /** + * Allows setting a callback for when the timer updates + * @param callback + */ + setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) { + this.onUpdateCallback = callback; + } + start() { if (!runtimeState.start()) { return false; @@ -79,6 +83,12 @@ export class TimerService { // renew end callback clearTimeout(this.endCallback); const state = runtimeState.getState(); + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.timer.expectedFinish === null) { + throw new Error('TimerService.addTime: expectedFinish is negative'); + } + } this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish); return true; } @@ -89,7 +99,7 @@ export class TimerService { update() { const updateResult = runtimeState.update(); // pass the result to the parent - this.onUpdateCallback(updateResult); + this.onUpdateCallback?.(updateResult); } /** diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 1097a324c..1121f359e 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -10,7 +10,6 @@ import { getTotalDuration, normaliseEndTime, skippedOutOfEvent, - updateRoll, } from '../timerUtils.js'; import { RuntimeState } from '../../stores/runtimeState.js'; @@ -1237,196 +1236,6 @@ test('normaliseEndTime()', () => { expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected); }); -describe('updateRoll()', () => { - it('it updates running events correctly', () => { - const timers = { - eventNow: { - id: '1', - }, - clock: 11, - timer: { - current: 10, - expectedFinish: 100, - secondaryTimer: null, - startedAt: 1, - }, - _timer: { - secondaryTarget: null, - }, - } as RuntimeState; - - const expected = { - updatedTimer: 100 - 11, - updatedSecondaryTimer: null, // usually clock - expectedFinish - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - - // test that it can jump time - timers.timer.expectedFinish = 1000; - timers.clock = 600; - expected.updatedTimer = 1000 - 600; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('it updates secondary timer', () => { - const timers = { - eventNow: null, - clock: 11, - timer: { - current: null, - expectedFinish: null, - secondaryTimer: 1, - }, - _timer: { - secondaryTarget: 15, - }, - } as RuntimeState; - - const expected = { - updatedTimer: null, - updatedSecondaryTimer: 15 - 11, // countdown to secondary - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('flags an event end', () => { - const timers = { - eventNow: { - id: '1', - }, - clock: 12, - timer: { - startedAt: 0, - current: 10, - expectedFinish: 11, - secondaryTimer: null, - }, - _timer: { - secondaryTarget: null, - }, - } as RuntimeState; - - const expected = { - updatedTimer: -1, - updatedSecondaryTimer: null, - doRollLoad: true, - isFinished: true, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('secondary events do not trigger event ends', () => { - const timers = { - eventNow: null, - clock: 16, - timer: { - startedAt: null, - current: null, - expectedFinish: null, - secondaryTimer: 1, - }, - _timer: { - secondaryTarget: 15, - }, - } as RuntimeState; - - const expected = { - updatedTimer: null, - updatedSecondaryTimer: -1, - doRollLoad: true, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('when a secondary timer is finished, it prompts for new event load', () => { - const timers = { - eventNow: null, - clock: 15, - timer: { - current: null, - expectedFinish: null, - secondaryTimer: 0, - }, - _timer: { - secondaryTarget: 15, - }, - } as RuntimeState; - - const expected = { - updatedTimer: null, - updatedSecondaryTimer: 0, - doRollLoad: true, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('counts over midnight', () => { - const timers = { - eventNow: { - id: '1', - }, - clock: dayInMs - 10, - timer: { - current: 25, - expectedFinish: 10, - startedAt: 1000, - secondaryTimer: null, - }, - _timer: { - secondaryTarget: null, - }, - } as RuntimeState; - - const expected = { - updatedTimer: 20, - updatedSecondaryTimer: null, - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('rolls over midnight', () => { - const timers = { - eventNow: { - id: '1', - }, - clock: 10, - timer: { - current: dayInMs, - expectedFinish: 10, - startedAt: 1000, - secondaryTimer: null, - }, - _timer: { - secondaryTarget: null, - }, - } as RuntimeState; - - const expected = { - updatedTimer: dayInMs, - updatedSecondaryTimer: null, - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); -}); - describe('getRuntimeOffset()', () => { it('is the difference between scheduled and when we actually started', () => { const state = { @@ -1554,7 +1363,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: null, }, - _timer: { pausedAt: null, secondaryTarget: null }, + _timer: { pausedAt: null }, } as RuntimeState; const offset = getRuntimeOffset(state); @@ -1595,7 +1404,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: null, }, - _timer: { pausedAt: null, secondaryTarget: null }, + _timer: { pausedAt: null }, } as RuntimeState; const offset = getRuntimeOffset(state); @@ -1630,7 +1439,7 @@ describe('getRuntimeOffset()', () => { runtime: { selectedEventIndex: 0, numEvents: 1, - offset: null, + offset: 0, plannedStart: 77400000, // 21:30:00 plannedEnd: 81000000, // 22:30:00 actualStart: 78000000, // 21:40:00 @@ -1647,7 +1456,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: 78000000, }, - _timer: { pausedAt: null, secondaryTarget: null }, + _timer: { pausedAt: null }, } as RuntimeState; const offset = getRuntimeOffset(state); @@ -1682,7 +1491,7 @@ describe('getRuntimeOffset()', () => { runtime: { selectedEventIndex: 0, numEvents: 1, - offset: null, + offset: 0, plannedStart: 77400000, // 21:30:00 plannedEnd: 81000000, // 22:30:00 actualStart: 78000000, // 21:40:00 @@ -1699,7 +1508,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: 78000000, }, - _timer: { pausedAt: null, secondaryTarget: null }, + _timer: { pausedAt: null }, } as RuntimeState; const offset = getRuntimeOffset(state); @@ -1722,7 +1531,7 @@ describe('getRuntimeOffset()', () => { runtime: { selectedEventIndex: 0, numEvents: 1, - offset: null, + offset: 0, plannedStart: 77400000, // 21:30:00 plannedEnd: 81000000, // 22:30:00 actualStart: 82000000, // 22:46:40 <--- started now @@ -1739,7 +1548,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: 82000000, // <--- started now }, - _timer: { pausedAt: null, secondaryTarget: null }, + _timer: { pausedAt: null }, } as RuntimeState; const updateCurrent = getCurrent(state); @@ -1887,7 +1696,7 @@ describe('getTimerPhase()', () => { runtime: { selectedEventIndex: null, numEvents: 1, - offset: null, + offset: 0, plannedStart: 55860000, plannedEnd: 55880000, actualStart: null, @@ -1909,7 +1718,6 @@ describe('getTimerPhase()', () => { forceFinish: null, totalDelay: 0, pausedAt: null, - secondaryTarget: 55860000, }, } as RuntimeState; @@ -1927,7 +1735,7 @@ describe('getTimerPhase()', () => { runtime: { selectedEventIndex: null, numEvents: 1, - offset: null, + offset: 0, plannedStart: 55860000, plannedEnd: 55880000, actualStart: null, @@ -1949,7 +1757,6 @@ describe('getTimerPhase()', () => { forceFinish: null, totalDelay: 0, pausedAt: null, - secondaryTarget: 55860000, }, } as RuntimeState; diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 43a45a55b..5936ffcd3 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -29,7 +29,9 @@ import { getEventWithId, getPlayableEvents, } from '../rundown-service/rundownUtils.js'; +import { skippedOutOfEvent } from '../timerUtils.js'; import { integrationService } from '../integration-service/IntegrationService.js'; + import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; /** @@ -37,18 +39,21 @@ import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './ru * Coordinating with necessary services */ class RuntimeService { - private eventTimer: TimerService | null = null; + private eventTimer: TimerService; private lastIntegrationClockUpdate = -1; private lastIntegrationTimerValue = -1; /** last time we updated the socket */ static previousTimerUpdate: number; - static previousTimerValue: MaybeNumber; + static previousTimerValue: MaybeNumber; // previous timer value, could be null static previousClockUpdate: number; + /** last known state */ static previousState: RuntimeState; - constructor() { + constructor(timerService: TimerService) { + this.eventTimer = timerService; + RuntimeService.previousTimerUpdate = -1; RuntimeService.previousTimerValue = -1; RuntimeService.previousClockUpdate = -1; @@ -59,7 +64,45 @@ class RuntimeService { @broadcastResult checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) { const newState = runtimeState.getState(); - if (hasTimerFinished) { + + // 1. find if we need to dispatch integrations related to the phase + const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase; + if (timerPhaseChanged) { + if (newState.timer.phase === TimerPhase.Warning) { + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onWarning); + }); + } else if (newState.timer.phase === TimerPhase.Danger) { + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onDanger); + }); + } + } + + // 2. handle edge cases related to roll + if (newState.timer.playback === Playback.Roll) { + // check if we need to call roll again + const needsEvent = + newState.eventNow === null + ? true + : skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit); + const hasFinishedRoll = hasTimerFinished && shouldCallRoll; + if (shouldCallRoll || needsEvent) { + if (hasFinishedRoll) { + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onFinish); + }); + } + + // we dont call this.roll because we need to bypass the checks + const rundown = getPlayableEvents(); + // TODO: by not calling roll, we dont get the events + this.eventTimer.roll(rundown); + } + } + + // 3. find if we need to process actions related to the timer finishing + if (newState.timer.playback !== Playback.Roll && hasTimerFinished) { process.nextTick(() => { integrationService.dispatch(TimerLifeCycle.onFinish); }); @@ -77,10 +120,8 @@ class RuntimeService { } } - const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play; - const shouldUpdateTimer = - hasRunningTimer && getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current); - + // 4. find if we need to update the timer + const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current); if (shouldUpdateTimer) { process.nextTick(() => { integrationService.dispatch(TimerLifeCycle.onUpdate); @@ -89,6 +130,7 @@ class RuntimeService { this.lastIntegrationTimerValue = newState.timer.current ?? -1; } + // 5. find if we need to update the clock const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock); if (shouldUpdateClock) { process.nextTick(() => { @@ -97,42 +139,12 @@ class RuntimeService { this.lastIntegrationClockUpdate = newState.clock; } - - if (shouldCallRoll) { - // we dont call this.roll because we need to bypass the checks - const rundown = getPlayableEvents(); - this.eventTimer.roll(rundown); - } - - const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase; - - if (timerPhaseChanged) { - switch (newState.timer.phase) { - case TimerPhase.Warning: - process.nextTick(() => { - integrationService.dispatch(TimerLifeCycle.onWarning); - }); - break; - case TimerPhase.Danger: - process.nextTick(() => { - integrationService.dispatch(TimerLifeCycle.onDanger); - }); - break; - default: - break; - } - } } /** delay initialisation until we have a restore point */ init(resumable: RestorePoint | null) { logger.info(LogOrigin.Server, 'Runtime service started'); - // calculate at 30fps, refresh at 1fps - this.eventTimer = new TimerService({ - refresh: timerConfig.updateRate, - updateInterval: timerConfig.notificationRate, - onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult), - }); + this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult)); if (resumable) { this.resume(resumable); @@ -553,8 +565,16 @@ class RuntimeService { } } -export const runtimeService = new RuntimeService(); +// calculate at 30fps, refresh at 1fps +const eventTimer = new TimerService({ + refresh: timerConfig.updateRate, + updateInterval: timerConfig.notificationRate, +}); +export const runtimeService = new RuntimeService(eventTimer); +/** + * Decorator manages side effects from updating the runtime + */ function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 45898f502..3ad68df87 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,7 +1,6 @@ import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; -import { timerConfig } from '../config/config.js'; /** * handle events that span over midnight @@ -56,6 +55,12 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber { */ export function getCurrent(state: RuntimeState): number { + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.eventNow === null || state.timer.duration === null) { + throw new Error('timerUtils.getCurrent: invalid state received'); + } + } const { startedAt, duration, addedTime } = state.timer; const { timerType, timeStart, timeEnd } = state.eventNow; const { pausedAt } = state._timer; @@ -88,6 +93,12 @@ export function getCurrent(state: RuntimeState): number { * @returns {boolean} */ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean { + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.timer.expectedFinish === null || state.timer.startedAt === null) { + throw new Error('timerUtils.skippedOutOfEvent: invalid state received'); + } + } const { startedAt, expectedFinish } = state.timer; const { clock } = state; @@ -246,54 +257,6 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIn }; }; -/** - * @description Implements update functions for roll mode - * @param {RuntimeState} - * @returns object with selection variables - */ -export const updateRoll = (state: RuntimeState) => { - const { current, expectedFinish, startedAt, secondaryTimer } = state.timer; - const { secondaryTarget } = state._timer; - const { clock } = state; - const selectedEventId = state.eventNow?.id ?? null; - - // timers - let updatedTimer = current; - let updatedSecondaryTimer = secondaryTimer; - // whether rollLoad should be called: force reload of events - let doRollLoad = false; - // whether finished event should trigger - let isPrimaryFinished = false; - - if (selectedEventId && current !== null) { - // if we have something selected and a timer, we are running - - const finishAt = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs; - updatedTimer = finishAt - clock; - - if (updatedTimer > dayInMs) { - updatedTimer -= dayInMs; - } - - if (updatedTimer <= timerConfig.triggerAhead) { - isPrimaryFinished = true; - // we need a new event - doRollLoad = true; - } - } else if (secondaryTimer >= 0) { - // if secondaryTimer is running we are in waiting to roll - - updatedSecondaryTimer = secondaryTarget - clock; - - if (updatedSecondaryTimer <= 0) { - // we need a new event - doRollLoad = true; - } - } - - return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished }; -}; - /** * Calculates difference between the runtime and the schedule of an event * Positive offset is time ahead @@ -305,6 +268,14 @@ export function getRuntimeOffset(state: RuntimeState): number { return 0; } + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + // we know current exists as long as eventNow exists + if (state.timer.current === null) { + throw new Error('timerUtils.calculate: current must be set'); + } + } + const { clock } = state; const { timeStart, timerType } = state.eventNow; const { addedTime, current, startedAt } = state.timer; @@ -356,7 +327,7 @@ export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: n */ export function getExpectedEnd(state: RuntimeState): MaybeNumber { // there is no expected end if we havent started - if (state.runtime.actualStart === null) { + if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) { return null; } return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay; @@ -367,7 +338,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber { * @param state * @returns */ -function isPlaybackActive(state: RuntimeState): boolean { +export function isPlaybackActive(state: RuntimeState): boolean { return ( state.timer.playback === Playback.Play || state.timer.playback === Playback.Pause || diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index a355572f8..6b372dfb3 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -36,7 +36,6 @@ const mockState = { }, _timer: { pausedAt: null, - secondaryTarget: null, }, } as RuntimeState; diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 945b7e4c5..0bff88afa 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,9 +1,8 @@ -import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types'; +import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState } from 'ontime-types'; import { calculateDuration, dayInMs } from 'ontime-utils'; import { clock } from '../services/Clock.js'; import { RestorePoint } from '../services/RestoreService.js'; - import { getCurrent, getExpectedEnd, @@ -11,32 +10,32 @@ import { getRollTimers, getRuntimeOffset, getTimerPhase, - skippedOutOfEvent, - updateRoll, + isPlaybackActive, } from '../services/timerUtils.js'; import { timerConfig } from '../config/config.js'; const initialRuntime: Runtime = { - selectedEventIndex: null, - numEvents: 0, - offset: 0, - plannedStart: 0, - plannedEnd: 0, - actualStart: null, - expectedEnd: null, + selectedEventIndex: null, // changes if rundown changes or we load a new event + numEvents: 0, // change initiated by user + offset: 0, // changes at runtime + plannedStart: 0, // only changes if event changes + plannedEnd: 0, // only changes if event changes + actualStart: null, // set once we start the timer + expectedEnd: null, // changes with runtime, based on offset } as const; 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, - phase: TimerPhase.None, - playback: Playback.Stop, - secondaryTimer: null, - startedAt: null, + current: null, // changes on every update + duration: null, // only changes if event changes + elapsed: null, // changes on every update + // TODO: expected finish could account for midnight, we cleanup in the clients + expectedFinish: null, // change can only be initiated by user + finishedAt: null, // can change on update or user action + phase: TimerPhase.None, // can change on update or user action + playback: Playback.Stop, // change initiated by user + secondaryTimer: null, // change on every update + startedAt: null, // change can only be initiated by user } as const; export type RuntimeState = { @@ -49,10 +48,9 @@ export type RuntimeState = { timer: TimerState; // private properties of the timer calculations _timer: { - forceFinish: MaybeNumber; + forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time totalDelay: number; // this value comes from rundown service pausedAt: MaybeNumber; - secondaryTarget: MaybeNumber; }; }; @@ -68,7 +66,6 @@ const runtimeState: RuntimeState = { forceFinish: null, totalDelay: 0, pausedAt: null, - secondaryTarget: null, }, }; @@ -93,7 +90,6 @@ export function clear() { // we maintain the total delay runtimeState._timer.pausedAt = null; - runtimeState._timer.secondaryTarget = null; } /** @@ -125,7 +121,8 @@ export function updateRundownData(rundownData: RundownData) { runtimeState.runtime.numEvents = rundownData.numEvents; runtimeState.runtime.plannedStart = rundownData.firstStart; - runtimeState.runtime.plannedEnd = rundownData.firstStart + rundownData.totalDuration; + runtimeState.runtime.plannedEnd = + rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration; runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); } @@ -282,7 +279,6 @@ export function start(state: RuntimeState = runtimeState): boolean { } state.clock = clock.timeNow(); state.timer.secondaryTimer = null; - state._timer.secondaryTarget = null; // add paused time if it exists if (state._timer.pausedAt) { @@ -374,73 +370,60 @@ export type UpdateResult = { }; export function update(): UpdateResult { - let hasTimerFinished = false; - let shouldCallRoll = false; // we also need to call roll if a secondary timer has finished + // 0. there are some things we always do + runtimeState.clock = clock.timeNow(); // we update the clock on every update call - const previousTime = runtimeState.clock; - runtimeState.clock = clock.timeNow(); - - // we call integrations if we update timers - if (runtimeState.timer.playback === Playback.Roll) { - const result = onRollUpdate(); - shouldCallRoll = result.doRoll; - hasTimerFinished = result.isFinished; - } else if (runtimeState.timer.startedAt !== null) { - // we only update timer if a timer has been started - const result = onPlayUpdate(); - hasTimerFinished = 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; + // 1. is playback idle? + if (!isPlaybackActive(runtimeState)) { + return updateIfIdle(); } - // update timer phase - runtimeState.timer.phase = getTimerPhase(runtimeState); + // 2. are we waiting to roll? + if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { + return updateIfWaitingToRoll(runtimeState.timer.secondaryTimer); + } - // update offset + // 3. at this point we know that we are playing an event + // reset data + runtimeState.timer.secondaryTimer = null; + + // update timer state + if (!runtimeState.timer.duration) { + throw new Error('Timer duration is not set'); + } + + runtimeState.timer.current = getCurrent(runtimeState); + runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + runtimeState.timer.phase = getTimerPhase(runtimeState); + runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; + + // update runtime, needs up-to-date timer state runtimeState.runtime.offset = getRuntimeOffset(runtimeState); runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState); - return { - hasTimerFinished, - shouldCallRoll, - }; + const finishedNow = + Boolean(runtimeState._timer.forceFinish) || + (runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null); - function onRollUpdate() { - const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.skipLimit); - 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 }; + if (finishedNow) { + // reset state + runtimeState._timer.forceFinish; + runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock; + } else { + runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); } - function onPlayUpdate() { - let isFinished = false; - runtimeState.timer.current = getCurrent(runtimeState); - const shouldForceFinish = runtimeState._timer.forceFinish !== null; - const finishedNow = - shouldForceFinish || - (runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null); + return { hasTimerFinished: finishedNow, shouldCallRoll: finishedNow }; - if (runtimeState.timer.playback === Playback.Play && finishedNow) { - runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock; - isFinished = true; - } else { - runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); - } + function updateIfIdle() { + // if nothing is running, nothing to do + return { hasTimerFinished: false, shouldCallRoll: false }; + } - if (shouldForceFinish) { - runtimeState._timer.forceFinish = null; - } - runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; - - return { isFinished }; + function updateIfWaitingToRoll(targetTime: number) { + runtimeState.timer.secondaryTimer = targetTime - runtimeState.clock; + runtimeState.timer.phase = TimerPhase.Pending; + return { hasTimerFinished: false, shouldCallRoll: runtimeState.timer.secondaryTimer < 0 }; } } @@ -454,7 +437,6 @@ export function roll(rundown: OntimeEvent[]) { 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 = @@ -475,8 +457,8 @@ export function roll(rundown: OntimeEvent[]) { // account for day after const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; // nothing now, but something coming up + runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock; - runtimeState._timer.secondaryTarget = nextStart; } runtimeState.timer.playback = Playback.Roll; diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 234f8aa00..302f4e62e 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -7,18 +7,27 @@ export enum TimerPhase { Warning = 'warning', Danger = 'danger', Overtime = 'overtime', - Pending = 'pending', // used for waiting to roll + /** used for waiting to roll */ + Pending = 'pending', } export type TimerState = { - addedTime: number; // time added by user, can be negative - current: MaybeNumber; // running countdown - duration: MaybeNumber; // normalised duration of current event - elapsed: MaybeNumber; // elapsed time in current timer - expectedFinish: MaybeNumber; // time we expect timer to finish - finishedAt: MaybeNumber; // only if timer has already finished + /** time added by user, can be negative */ + addedTime: number; + /** running countdown */ + current: MaybeNumber; + /** normalised duration of current event */ + duration: MaybeNumber; + /** elapsed time in current timer */ + elapsed: MaybeNumber; + /** time we expect timer to finish */ + expectedFinish: MaybeNumber; + /** only if timer has already finished */ + finishedAt: MaybeNumber; phase: TimerPhase; playback: Playback; - secondaryTimer: MaybeNumber; // used for roll mode - startedAt: MaybeNumber; // only if timer has already started + /** used for roll mode */ + secondaryTimer: MaybeNumber; + /** only if timer has already started */ + startedAt: MaybeNumber; };