import { EndAction, isOntimeEvent, isPlayableEvent, LogOrigin, MaybeNumber, OffsetMode, OntimeEvent, Playback, TimerLifeCycle, TimerPhase, TimerState, } from 'ontime-types'; import { millisToString, validatePlayback } from 'ontime-utils'; import { deepEqual } from 'fast-equals'; import { logger } from '../../classes/Logger.js'; import * as runtimeState from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js'; import { eventStore } from '../../stores/EventStore.js'; import { triggerReportEntry } from '../../api-data/report/report.service.js'; import { timerConfig } from '../../setup/config.js'; import { triggerAutomations } from '../../api-data/automation/automation.service.js'; import { EventTimer } from '../EventTimer.js'; import { RestorePoint, restoreService } from '../RestoreService.js'; import { findNext, findPrevious, getEventAtIndex, getNextEventWithCue, getEntryWithId, getCurrentRundown, getTimedEvents, getRundownData, } from '../rundown-service/rundownUtils.js'; import { skippedOutOfEvent } from '../timerUtils.js'; import { getEventOrder } from '../rundown-service/rundownCache.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; type RuntimeStateEventKeys = keyof Pick; /** * Service manages runtime status of app * Coordinating with necessary services */ class RuntimeService { private readonly eventTimer: EventTimer; private lastIntegrationClockUpdate = -1; private lastIntegrationTimerValue = -1; /** last time we updated the socket */ static previousTimerUpdate: number; static previousRuntimeUpdate: number; static previousTimerValue: MaybeNumber; // previous timer value, could be null static previousClockUpdate: number; /** last known state */ static previousState: RuntimeState; constructor(eventTimer: EventTimer) { this.eventTimer = eventTimer; RuntimeService.previousTimerUpdate = -1; RuntimeService.previousRuntimeUpdate = -1; RuntimeService.previousTimerValue = -1; RuntimeService.previousClockUpdate = -1; RuntimeService.previousState = {} as RuntimeState; } @broadcastResult setOffsetMode(mode: OffsetMode) { runtimeState.setOffsetMode(mode); } /** * Checks result of an update and notifies integrations as needed * This is the only exception of a private method that has broadcast result * */ @broadcastResult private checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) { const newState = runtimeState.getState(); // 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(() => { triggerAutomations(TimerLifeCycle.onWarning, newState); }); } else if (newState.timer.phase === TimerPhase.Danger) { process.nextTick(() => { triggerAutomations(TimerLifeCycle.onDanger, newState); }); } } // 2. handle edge cases related to roll if (newState.timer.playback === Playback.Roll) { // check if we need to call any side effects const keepOffset = newState.runtime.offset; if (hasSecondaryTimerFinished) { // if the secondary timer has finished, we need to call roll // since event is already loaded this.rollLoaded(keepOffset); } else if (hasTimerFinished) { // if the timer has finished, we need to load next and keep rolling process.nextTick(() => { triggerAutomations(TimerLifeCycle.onFinish, newState); }); this.handleLoadNext(); this.rollLoaded(keepOffset); } else if ( // if there is no previous clock, we could not have skipped RuntimeService.previousState?.clock && skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit) ) { // if we have skipped out of the event, we will recall roll // to push the playback to the right place // this comes with the caveat that we will lose our runtime data logger.warning(LogOrigin.Playback, 'Time skip detected, reloading roll'); this.roll(true); } } // 3. find if we need to process actions related to the timer finishing if (newState.timer.playback === Playback.Play && hasTimerFinished) { process.nextTick(() => { triggerAutomations(TimerLifeCycle.onFinish, newState); }); // handle end action if there was a timer playing // actions are added to the queue stack to ensure that the order of operations is maintained if (newState.eventNow) { if (newState.eventNow.endAction === EndAction.LoadNext) { setTimeout(this.loadNext.bind(this), 0); } else if (newState.eventNow.endAction === EndAction.PlayNext) { setTimeout(this.startNext.bind(this), 0); } } } // 4. find if we need to update the timer const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current); if (shouldUpdateTimer) { process.nextTick(() => { triggerAutomations(TimerLifeCycle.onUpdate, newState); }); 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(() => { triggerAutomations(TimerLifeCycle.onClock, newState); }); this.lastIntegrationClockUpdate = newState.clock; } } /** delay initialisation until we have a restore point */ public init(resumable: RestorePoint | null) { logger.info(LogOrigin.Server, 'Runtime service started'); this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult)); if (resumable) { this.resume(resumable); } } public shutdown() { if (this.eventTimer) { logger.info(LogOrigin.Server, 'Runtime service shutting down'); this.eventTimer.shutdown(); } } /** * 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; const nextPublic = state.publicEventNext?.id; return ( (now !== undefined && affectedIds.includes(now)) || (nowPublic !== undefined && affectedIds.includes(nowPublic)) || (next !== undefined && affectedIds.includes(next)) || (nextPublic !== undefined && affectedIds.includes(nextPublic)) ); } private isNewNext() { const timedEvents = getTimedEvents(); const state = runtimeState.getState(); const now = state.eventNow?.id; const next = state.eventNext?.id; // check whether the index of now and next are consecutive const indexNow = timedEvents.findIndex((event) => event.id === now); const indexNext = timedEvents.findIndex((event) => event.id === next); if (indexNext - indexNow !== 1) { return true; } // iterate through timed events and see if there are public events between nowPublic and nextPublic const nowPublic = state.publicEventNow?.id; const nextPublic = state.publicEventNext?.id; let foundNew = false; let isAfter = false; for (const event of timedEvents) { if (!isAfter) { if (event.id === nowPublic) { isAfter = true; } } else { if (event.id === nextPublic) { break; } if (event.isPublic) { foundNew = true; break; } } } return foundNew; } /** * Called when the underlying data has changed, * we check if the change affects the runtime */ public notifyOfChangedEvents(affectedIds?: string[]) { const state = runtimeState.getState(); const hasLoadedElements = state.eventNow !== null || state.eventNext !== null; if (!hasLoadedElements) { return; } // we need to reload in a few scenarios: // 1. we are not confident that changes do not affect running event (eg. all events where changed) const safeOption = affectedIds === undefined; // 2. the edited event is in memory (now or next) running // behind conditional to avoid doing unnecessary work const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds); // 3. the edited event replaces next event let isNext = false; // if we are not sure, or the event is in memory, we reload if (safeOption || eventInMemory) { if (state.eventNow !== null) { // load stuff again, but keep running if our events still exist const eventNow = getEntryWithId(state.eventNow.id); if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) { // maybe the event was deleted or the skip state was changed runtimeState.stop(); return; } const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id; if (onlyChangedNow) { runtimeState.updateLoaded(eventNow); } else { const rundown = getCurrentRundown(); const { timedEventsOrder } = getEventOrder(); runtimeState.updateAll(rundown, timedEventsOrder); } return; } } // Maybe the event will become the next isNext = this.isNewNext(); if (isNext) { const timedEvents = getTimedEvents(); runtimeState.loadNext(timedEvents); } } /** * makes calls for loading and starting given event * @param {PlayableEvent} event * @param {Partial} initialData * @return {boolean} success - whether an event was loaded */ private loadEvent(event: OntimeEvent, initialData?: Partial): boolean { if (!isPlayableEvent(event)) { logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); return false; } const previousState = runtimeState.getState(); const { rundown, rundownOrder } = getRundownData(); const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData); if (success) { logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); const newState = runtimeState.getState(); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerAutomations(TimerLifeCycle.onLoad, newState); }); } return success; } public getRuntimeState() { return { playback: runtimeState.getState().timer.playback }; } /** * starts event matching given ID * @param {string} eventId * @return {boolean} success - whether an event was started */ @broadcastResult public startById(eventId: string): boolean { const event = getEntryWithId(eventId); if (!event || !isOntimeEvent(event)) { return false; } const loaded = this.loadEvent(event); if (!loaded) { return false; } return this.handleStart(); } /** * starts an event at index * @param {number} eventIndex * @return {boolean} success - whether an event was started */ @broadcastResult public startByIndex(eventIndex: number): boolean { const event = getEventAtIndex(eventIndex); if (!event) { return false; } const loaded = this.loadEvent(event); if (!loaded) { return false; } return this.handleStart(); } /** * starts first event matching given cue * @param {string} cue * @return {boolean} success - whether an event was started */ @broadcastResult public startByCue(cue: string): boolean { const event = getNextEventWithCue(cue); //TODO: add index if (!event) { return false; } const loaded = this.loadEvent(event); if (!loaded) { return false; } return this.handleStart(); } /** * loads event matching given ID * @param {string} eventId * @return {boolean} success - whether an event was loaded */ @broadcastResult public loadById(eventId: string): boolean { const event = getEntryWithId(eventId); if (!event || !isOntimeEvent(event)) { return false; } return this.loadEvent(event); } /** * loads event matching given ID * @param {number} eventIndex * @return {boolean} success - whether an event was loaded */ @broadcastResult public loadByIndex(eventIndex: number): boolean { const event = getEventAtIndex(eventIndex); if (!event) { return false; } return this.loadEvent(event); } /** * loads first event matching given cue * @param {string} cue * @return {boolean} success - whether an event was loaded */ @broadcastResult public loadByCue(cue: string): boolean { const event = getNextEventWithCue(cue); //TODO: add index if (!event) { return false; } return this.loadEvent(event); } /** * Contains logic for loading the previous event * * we need to isolate handleLoadPrevious so we have control over the side effects * startSelected being a private function does not trigger emits */ private handleLoadPrevious(): boolean { const state = runtimeState.getState(); const previousEvent = findPrevious(state.eventNow?.id); if (previousEvent) { return this.loadEvent(previousEvent); } return false; } /** * Loads event before currently selected * @return {boolean} success - whether an event was loaded */ @broadcastResult public loadPrevious(): boolean { return this.handleLoadPrevious(); } /** * Contains logic for loading the next event * * we need to isolate handleLoadNext so we have control over the side effects * startSelected being a private function does not trigger emits * and pass on runtime offset in case of roll mode */ private handleLoadNext(): boolean { const state = runtimeState.getState(); const nextEvent = findNext(state.eventNow?.id); if (nextEvent) { if (state.timer.playback === Playback.Roll) { return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart }); } return this.loadEvent(nextEvent); } logger.info(LogOrigin.Playback, 'No next event found! Continuing playback'); return false; } /** * Loads event after currently selected * @return {boolean} success */ @broadcastResult public loadNext(): boolean { return this.handleLoadNext(); } /** * Contains logic for starting selected event * * we need to isolate handleStart so we have control over the side effects * startSelected being a private function does not trigger emits */ private handleStart(): boolean { const previousState = runtimeState.getState(); const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start; if (!canStart) { return false; } const didStart = this.eventTimer?.start() ?? false; const newState = runtimeState.getState(); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); if (didStart) { process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStart, newState); triggerAutomations(TimerLifeCycle.onStart, newState); }); } return didStart; } /** * Starts playback on selected event */ @broadcastResult public start(): boolean { return this.handleStart(); } /** * Starts playback on previous event */ @broadcastResult public startPrevious(): boolean { const hasPrevious = this.handleLoadPrevious(); if (!hasPrevious) { return false; } return this.handleStart(); } /** * Starts playback on next event */ @broadcastResult public startNext(): boolean { const hasNext = this.handleLoadNext(); if (!hasNext) { return false; } return this.handleStart(); } /** * Pauses playback on selected event */ @broadcastResult public pause() { const state = runtimeState.getState(); const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause; if (!canPause) { return; } this.eventTimer?.pause(); const newState = runtimeState.getState(); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { triggerAutomations(TimerLifeCycle.onPause, newState); }); } /** * Stops timer and unloads any events */ @broadcastResult public stop(): boolean { const previousState = runtimeState.getState(); const canStop = validatePlayback(previousState.timer.playback, previousState.timer.phase).stop; if (!canStop) { return false; } const didStop = this.eventTimer?.stop(); if (didStop) { const newState = runtimeState.getState(); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerAutomations(TimerLifeCycle.onStop, newState); }); return true; } return false; } /** * Reloads current event */ @broadcastResult public reload() { const state = runtimeState.getState(); if (state.eventNow) { return this.loadEvent(state.eventNow); } return false; } /** * Handles special case to call roll on a loaded event which we do not want to discard */ private rollLoaded(offset?: number) { const rundown = getCurrentRundown(); const { timedEventsOrder } = getEventOrder(); try { runtimeState.roll(rundown, timedEventsOrder, offset); } catch (error) { logger.error(LogOrigin.Server, `Roll: ${error}`); } } /** * Sets playback to roll */ @broadcastResult public roll(skipCheck: boolean = false) { const previousState = runtimeState.getState(); if (!skipCheck) { const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll; if (!canRoll) { return; } } try { const { rundown, rundownOrder } = getRundownData(); const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder); const newState = runtimeState.getState(); if (result.eventId !== previousState.eventNow?.id) { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerAutomations(TimerLifeCycle.onLoad, newState); }); } if (result.didStart) { process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStart, newState); triggerAutomations(TimerLifeCycle.onStart, newState); }); } } catch (error) { logger.error(LogOrigin.Server, `Roll: ${error}`); return; } const newState = runtimeState.getState(); if (previousState.timer.playback !== newState.timer.playback) { logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); } } /** * @description resume playback state given a restore point * @param restorePoint */ @broadcastResult public resume(restorePoint: RestorePoint) { const { selectedEventId, playback } = restorePoint; if (playback === Playback.Roll) { this.roll(); return; } if (!selectedEventId) { return; } // the db would have to change for the event not to exist // we do not know the reason for the crash, so we check anyway const event = getEntryWithId(selectedEventId); if (!isOntimeEvent(event) || !isPlayableEvent(event)) { return; } const { rundown, rundownOrder } = getRundownData(); runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder); logger.info(LogOrigin.Playback, 'Resuming playback'); } /** * Adds time to current event * @param {number} time - time to add in milliseconds */ @broadcastResult public addTime(time: number) { if (this.eventTimer.addTime(time)) { logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`); } } } // calculate at 30fps, refresh at 1fps const eventTimer = new EventTimer({ refresh: timerConfig.updateRate, updateInterval: timerConfig.notificationRate, }); export const runtimeService = new RuntimeService(eventTimer); /** * Decorator manages side effects from updating the runtime * This should only be applied to functions that are exposed for consumption * ie: whenever an external service makes a request, we update the state with the mutation result */ function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = function (...args: any[]) { // call the original method and get the state const result = originalMethod.apply(this, args); const state = runtimeState.getState(); // we do the comparison by explicitly for each property // to apply custom logic for different datasets // if a new event was loaded most things should update const hasNewLoaded = state.eventNow?.id !== RuntimeService.previousState?.eventNow?.id; // for the very fist run there will be nothing in the previousState so we force an update const justStarted = !RuntimeService.previousState?.timer; // offset mode has been changed const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode; // if playback changes most things should update const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback; // combine all big changes const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged; /** * Timer should be updated if * - big changes * - notification rate has been exceeded * - the timer has rolled over into the next UI display unit * * Then check if there is actually a change in the data */ const shouldUpdateTimer = (hasImmediateChanges || getForceUpdate(RuntimeService.previousTimerUpdate, state.clock) || getShouldTimerUpdate(RuntimeService.previousTimerValue, state.timer.current)) && !deepEqual(RuntimeService.previousState?.timer, state.timer); /** * Runtime should be updated if * - big changes * - the timer is updating so runtime also updates to keep them in sync ??? * - notification rate has been exceeded * * Then check if there is actually a change in the data */ const shouldRuntimeUpdate = (hasImmediateChanges || shouldUpdateTimer || getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) && !deepEqual(RuntimeService.previousState?.runtime, state.runtime); /** * the currentBlock object has no ticking values so we only need to check for equality */ const shouldBlockUpdate = !deepEqual(RuntimeService?.previousState.currentBlock, state.currentBlock); /** * Many other values are calculated based on the clock * so if any of them are updated we also need to send the clock * in case nothing else is updating the clock will bw updated at the notification rate */ const shouldUpdateClock = shouldUpdateTimer || shouldRuntimeUpdate || shouldBlockUpdate || getForceUpdate(RuntimeService.previousClockUpdate, state.clock); //Now we set all the updates on the eventstore and update the previous value if (hasChangedPlayback) { eventStore.set('onAir', state.timer.playback !== Playback.Stop); } if (shouldUpdateTimer) { eventStore.set('timer', state.timer); RuntimeService.previousTimerUpdate = state.clock; RuntimeService.previousTimerValue = state.timer.current; RuntimeService.previousState.timer = { ...state.timer }; } if (shouldRuntimeUpdate) { eventStore.set('runtime', state.runtime); RuntimeService.previousRuntimeUpdate = state.clock; RuntimeService.previousState.runtime = { ...state.runtime }; } if (shouldBlockUpdate) { eventStore.set('currentBlock', state.currentBlock); RuntimeService.previousState.currentBlock = { ...state.currentBlock }; } if (hasImmediateChanges) { saveRestoreState(state); } if (shouldUpdateClock) { RuntimeService.previousClockUpdate = state.clock; eventStore.set('clock', state.clock); } // Update the events if they have changed updateEventIfChanged('eventNow', state); updateEventIfChanged('publicEventNow', state); updateEventIfChanged('eventNext', state); updateEventIfChanged('publicEventNext', state); // Helper function to update an event if it has changed function updateEventIfChanged(eventKey: RuntimeStateEventKeys, state: runtimeState.RuntimeState) { const previous = RuntimeService.previousState?.[eventKey]; const now = state[eventKey]; // if there was nothing, and there is nothing, noop if (!previous?.id && !now?.id) { return; } // if load status changed, save new if (previous?.id !== now?.id) { storeKey(eventKey); return; } // maybe the event itself has changed if (!deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey])) { storeKey(eventKey); return; } function storeKey(eventKey: RuntimeStateEventKeys) { eventStore.set(eventKey, state[eventKey]); // @ts-expect-error -- not sure how to type this in a sane way RuntimeService.previousState[eventKey] = { ...state[eventKey] }; } } // Helper function to save the restore state function saveRestoreState(state: runtimeState.RuntimeState) { restoreService.save({ playback: state.timer.playback, selectedEventId: state.eventNow?.id ?? null, startedAt: state.timer.startedAt, addedTime: state.timer.addedTime, pausedAt: state._timer.pausedAt, firstStart: state.runtime.actualStart, blockStartAt: state.currentBlock.startedAt, }); } return result; }; return descriptor; }