refactor: partial state updates (#780)

* refactor: partial state updates

* refactor: less calls to restore

* refactor: add error stack trace to log
This commit is contained in:
Carlos Valente
2024-02-19 21:31:27 +01:00
committed by GitHub
parent c890251dad
commit 7ff75835ff
22 changed files with 523 additions and 361 deletions
+4 -1
View File
@@ -43,7 +43,8 @@ import { restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './modules/loadDemo.js';
import { getState, updateRundownData } from './stores/runtimeState.js';
import { setRundown, getPlayableEvents } from './services/rundown-service/RundownService.js';
import { setRundown } from './services/rundown-service/RundownService.js';
import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -284,11 +285,13 @@ export const shutdown = async (exitCode = 0) => {
process.on('exit', (code) => console.log(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => {
console.error('Error: unhandled rejection', error);
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
await shutdown(1);
});
process.on('uncaughtException', async (error) => {
console.error('Error: uncaught exception', error);
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`);
await shutdown(1);
});
@@ -16,5 +16,7 @@ export const config = {
};
export const timerConfig = {
timeSkipLimit: 1000,
skipLimit: 1000, // threshold of skip for recalculating
updateRate: 32, // how often do we update the timer
notificationRate: 1000, // how often do we notify clients and integrations
};
@@ -1,5 +1,6 @@
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { editEvent, getEventWithId } from '../services/rundown-service/RundownService.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js';
const whitelistedPayload = {
@@ -48,21 +49,23 @@ export function updateEvent(
newValue: OntimeEvent[typeof propertyName],
) {
const event = getEventWithId(eventId);
if (event) {
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
const propertiesToUpdate = { [propertyName]: newValue };
// Handles the special case for duration
// needs to be converted to milliseconds
if (propertyName === 'duration') {
propertiesToUpdate.duration = (newValue as number) * 1000;
propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration;
}
editEvent({ id: eventId, ...propertiesToUpdate });
} else {
if (!event) {
throw new Error(`Event with ID ${eventId} not found`);
}
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
const propertiesToUpdate = { [propertyName]: newValue };
// Handles the special case for duration
// needs to be converted to milliseconds
if (propertyName === 'duration') {
propertiesToUpdate.duration = (newValue as number) * 1000;
propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration;
}
const newEvent = editEvent({ id: eventId, ...propertiesToUpdate });
return newEvent;
}
@@ -185,9 +185,10 @@ const actionHandlers: Record<string, ActionHandler> = {
}
}
}
throw new Error('Invalid extratimer payload');
throw new Error('Invalid extra-timer payload');
},
};
/**
* Returns a value of type number, converting if necessary
* Otherwise throws
@@ -10,11 +10,10 @@ import {
deleteAllEvents,
deleteEvent,
editEvent,
getRundown,
reorderEvent,
swapEvents,
} from '../services/rundown-service/RundownService.js';
import { get as getCachedRundown } from '../services/rundown-service/rundownCache.js';
import { getNormalisedRundown, getRundown } from '../services/rundown-service/rundownUtils.js';
// Create controller for GET request to '/events'
// Returns -
@@ -26,7 +25,7 @@ export const rundownGetAll: RequestHandler = async (_req, res) => {
// Create controller for GET request to '/events/cached'
// Returns -
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<RundownCached>) => {
const cachedRundown = getCachedRundown();
const cachedRundown = getNormalisedRundown();
res.json(cachedRundown);
};
+126 -77
View File
@@ -1,52 +1,88 @@
import { EndAction, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types';
import { OntimeEvent, RuntimeStore } from 'ontime-types';
import { deepEqual } from 'fast-equals';
import * as runtimeState from '../stores/runtimeState.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { eventStore } from '../stores/EventStore.js';
import * as runtimeState from '../stores/runtimeState.js';
import type { RuntimeState, UpdateResult } from '../stores/runtimeState.js';
import { restoreService } from './RestoreService.js';
import { runtimeService } from './runtime-service/RuntimeService.js';
import { getPlayableEvents } from './rundown-service/RundownService.js';
/**
* Service manages Ontime's main timer
* It is responsible for streaming the data to the event store
*/
export class TimerService {
private _interval: NodeJS.Timer;
private _updateInterval: number;
private _refreshInterval: number;
private readonly _interval: NodeJS.Timer;
/** how often we update the socket */
static _updateInterval: number;
/** how often we recalculate */
static _refreshInterval: number;
/** last time we updated the socket */
static previousUpdate: number;
/** last known state */
static previousState: RuntimeState;
/** when timer will be finished */
private endCallback: NodeJS.Timer;
private onUpdateCallback: (updateResult: UpdateResult) => void;
/**
* @constructor
* @param {number} [timerConfig.refresh]
* @param {number} [timerConfig.updateInterval]
* @param {number} [timerConfig.refresh] how often we recalculate
* @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 }) {
this._refreshInterval = timerConfig.refresh;
this._updateInterval = timerConfig.updateInterval;
this._interval = setInterval(this.update, 32);
constructor(timerConfig: {
refresh: number;
updateInterval: number;
onUpdateCallback: (updateResult: UpdateResult) => void;
}) {
TimerService.previousUpdate = -1;
TimerService.previousState = {} as RuntimeState;
TimerService._updateInterval = timerConfig.updateInterval;
TimerService._refreshInterval = timerConfig.refresh;
this.onUpdateCallback = timerConfig.onUpdateCallback;
this._interval = setInterval(() => {
this.update();
this.onUpdateCallback;
}, TimerService._updateInterval);
}
@broadcastResult
start() {
// 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
if (runtimeState.start()) {
integrationService.dispatch(TimerLifeCycle.onStart);
if (!runtimeState.start()) {
return false;
}
const state = runtimeState.getState();
this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
return true;
}
@broadcastResult
pause() {
if (runtimeState.pause()) {
integrationService.dispatch(TimerLifeCycle.onPause);
if (!runtimeState.pause()) {
return false;
}
// cancel end callback
clearTimeout(this.endCallback);
return true;
}
@broadcastResult
stop() {
if (runtimeState.stop()) {
integrationService.dispatch(TimerLifeCycle.onStop);
if (!runtimeState.stop()) {
return false;
}
// cancel end callback
clearTimeout(this.endCallback);
return true;
}
/**
@@ -55,62 +91,40 @@ export class TimerService {
*/
@broadcastResult
addTime(amount: number): boolean {
return runtimeState.addTime(amount);
if (!runtimeState.addTime(amount)) {
return false;
}
// renew end callback
clearTimeout(this.endCallback);
const state = runtimeState.getState();
this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
return true;
}
/**
* Update the app at regular intervals
* @param {boolean} force whether we should force a broadcast of state
*/
@broadcastResult
update(force = false) {
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);
}
update() {
const updateResult = runtimeState.update();
if (doRoll) {
// TODO: escalate to parent
const rundown = 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 && newState.eventNow) {
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();
}
}
}
// pass the result to the parent
this.onUpdateCallback(updateResult);
}
/**
* Loads roll information into timer service
* @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');
}
runtimeState.roll(rundown);
}
shutdown() {
clearInterval(this._interval);
clearTimeout(this.endCallback);
}
}
@@ -118,29 +132,64 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
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();
// 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 do the comparison by explicitly for each property
// to apply custom logic for different datasets
// some of the data, we only update at intervals
const isTimeToUpdate = state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
// some changes need an immediate update
const hasSkippedBack = state.clock < TimerService.previousUpdate;
const justStarted = !TimerService.previousState?.timer;
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
const hasImmediateChanges = hasSkippedBack || justStarted || hasChangedPlayback;
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) {
eventStore.set('timer', state.timer);
TimerService.previousState.timer = { ...state.timer };
}
if (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime)) {
eventStore.set('runtime', state.runtime);
TimerService.previousState.runtime = { ...state.runtime };
}
// Update the events if they have changed
updateEventIfChanged('eventNow', state);
updateEventIfChanged('publicEventNow', state);
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
if (isTimeToUpdate) {
TimerService.previousUpdate = state.clock;
eventStore.set('clock', state.clock);
saveRestoreState(state);
}
// Helper function to update an event if it has changed
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) {
if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) {
eventStore.set(eventKey, state[eventKey]);
TimerService.previousState[eventKey] = { ...state[eventKey] };
}
}
// Helper function to save the restore state
function saveRestoreState(state: 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,
});
}
// 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,
firstStart: state.runtime.actualStart,
});
return result;
};
@@ -3,11 +3,11 @@ import {
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeRundown,
} from 'ontime-types';
import { getCueCandidate } from 'ontime-utils';
@@ -19,6 +19,7 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPlayableEvents } from './rundownUtils.js';
function generateEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
// we discard any UI provided events and add our own
@@ -44,7 +45,9 @@ function generateEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> |
* @param {object} eventData
* @return {OntimeRundownEntry}
*/
export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
export async function addEvent(
eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>,
): Promise<OntimeRundownEntry> {
// if the user didnt provide an index, we add the event to start
let atIndex = 0;
if (eventData?.after !== undefined) {
@@ -65,7 +68,7 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
notifyChanges({ timer: [newEvent.id], external: true });
// notify runtime that rundown size has changed
updateChangeNumEvents();
updateRuntimeOnChange();
return newEvent;
}
@@ -73,7 +76,6 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
/**
* deletes event by its ID
* @param eventId
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId: string) {
const scopedMutation = cache.mutateCache(cache.remove);
@@ -82,12 +84,11 @@ export async function deleteEvent(eventId: string) {
notifyChanges({ timer: [eventId], external: true });
// notify event loader that rundown size has changed
updateChangeNumEvents();
updateRuntimeOnChange();
}
/**
* deletes all events in database
* @returns {Promise<void>}
*/
export async function deleteAllEvents() {
const scopedMutation = cache.mutateCache(cache.removeAll);
@@ -97,6 +98,10 @@ export async function deleteAllEvents() {
notifyChanges({ external: true });
}
/**
* Apply patch to an element in rundown
* @param patch
*/
export async function editEvent(patch: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
if (isOntimeEvent(patch) && patch?.cue === '') {
throw new Error('Cue value invalid');
@@ -111,6 +116,11 @@ export async function editEvent(patch: Partial<OntimeEvent> | Partial<OntimeBloc
return newEvent;
}
/**
* Applies a patch to several elements in a rundown
* @param ids
* @param data
*/
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
const scopedMutation = cache.mutateCache(cache.batchEdit);
await scopedMutation({ patch: data, eventIds: ids });
@@ -123,7 +133,6 @@ export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>)
* @param {string} eventId - ID of event from, for sanity check
* @param {number} from - index of event from
* @param {number} to - index of event to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId: string, from: number, to: number) {
const scopedMutation = cache.mutateCache(cache.reorder);
@@ -158,7 +167,7 @@ export async function swapEvents(from: string, to: string) {
* Forces update in the store
* Called when we make changes to the rundown object
*/
function updateChangeNumEvents() {
function updateRuntimeOnChange() {
updateRundownData(getPlayableEvents());
}
@@ -167,12 +176,13 @@ function updateChangeNumEvents() {
*/
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) {
if (options.timer) {
const playableEvents = getPlayableEvents();
// notify timer service of changed events
// timer can be true or an array of changed IDs
if (Array.isArray(options.timer)) {
runtimeService.update(options.timer);
runtimeService.maybeUpdate(playableEvents, options.timer);
}
runtimeService.update();
runtimeService.maybeUpdate(playableEvents);
}
if (options.external) {
@@ -181,115 +191,11 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
}
}
/**
* returns entire unfiltered rundown
* @return {array}
*/
export function getRundown(): OntimeRundown {
return cache.getPersistedRundown();
}
/**
* returns all events of type OntimeEvent
* @return {array}
*/
export function getTimedEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
/**
* returns all events that can be loaded
* @return {array}
*/
export function getPlayableEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[];
}
/**
* returns number of events that can be loaded
* @return {number}
*/
export function getNumEvents(): number {
return getPlayableEvents().length;
}
/**
* returns an event given its index after filtering for OntimeEvents
* @param {number} eventIndex
* @return {OntimeEvent | undefined}
*/
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.at(eventIndex);
}
/**
* returns first event that matches a given ID
* @param {string} eventId
* @return {object | undefined}
*/
export function getEventWithId(eventId: string): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
}
/**
* returns first event that matches a given cue
* @param {string} cue
* @return {object | undefined}
*/
export function getEventWithCue(cue: string): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.find((event) => event.cue.toLowerCase() === cue.toLowerCase());
}
/**
* finds the previous event
* @return {object | undefined}
*/
export function findPrevious(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = timedEvents.at(newIndex) ?? null;
return previousEvent;
}
/**
* finds the next event
* @return {object | undefined}
*/
export function findNext(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = (currentIndex + 1) % timedEvents.length;
const nextEvent = timedEvents.at(newIndex);
return nextEvent ?? null;
}
/**
* Overrides the rundown with the given
* @param rundown
*/
export async function setRundown(rundown: OntimeRundown) {
cache.init(rundown);
await cache.init(rundown);
notifyChanges({ timer: true });
}
@@ -0,0 +1,111 @@
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached } from 'ontime-types';
import * as cache from './rundownCache.js';
export function getNormalisedRundown(): RundownCached {
return cache.get();
}
/**
* returns entire unfiltered rundown
* @return {array}
*/
export function getRundown(): OntimeRundown {
return cache.getPersistedRundown();
}
/**
* returns all events of type OntimeEvent
* @return {array}
*/
export function getTimedEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
/**
* returns all events that can be loaded
* @return {array}
*/
export function getPlayableEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[];
}
/**
* returns number of events that can be loaded
* @return {number}
*/
export function getNumEvents(): number {
return getPlayableEvents().length;
}
/**
* returns an event given its index after filtering for OntimeEvents
* @param {number} eventIndex
* @return {OntimeEvent | undefined}
*/
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.at(eventIndex);
}
/**
* returns first event that matches a given ID
* @param {string} eventId
* @return {object | undefined}
*/
export function getEventWithId(eventId: string): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
}
/**
* returns first event that matches a given cue
* @param {string} cue
* @return {object | undefined}
*/
export function getEventWithCue(cue: string): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.find((event) => event.cue.toLowerCase() === cue.toLowerCase());
}
/**
* finds the previous event
* @return {object | undefined}
*/
export function findPrevious(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = timedEvents.at(newIndex) ?? null;
return previousEvent;
}
/**
* finds the next event
* @return {object | undefined}
*/
export function findNext(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) {
return null;
}
// if there is no event running, go to first
if (!currentEventId) {
return timedEvents.at(0) ?? null;
}
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId);
const newIndex = (currentIndex + 1) % timedEvents.length;
const nextEvent = timedEvents.at(newIndex);
return nextEvent ?? null;
}
@@ -1,10 +1,12 @@
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils';
import { TimerService } from '../TimerService.js';
import { logger } from '../../classes/Logger.js';
import { RestorePoint } from '../RestoreService.js';
import * as runtimeState from '../../stores/runtimeState.js';
import {
findNext,
findPrevious,
@@ -12,7 +14,9 @@ import {
getEventWithCue,
getEventWithId,
getPlayableEvents,
} from '../rundown-service/RundownService.js';
} from '../rundown-service/rundownUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js';
import { timerConfig } from '../../config/config.js';
/**
* Service manages runtime status of app
@@ -20,12 +24,55 @@ import {
*/
class RuntimeService {
private eventTimer: TimerService | null = null;
private lastOnUpdate = -1;
/** Checks result of an update and notifies integrations as needed */
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
const newState = runtimeState.getState();
if (hasTimerFinished) {
integrationService.dispatch(TimerLifeCycle.onFinish);
// 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.timer.playback === Playback.Play && newState.eventNow) {
if (newState.eventNow.endAction === EndAction.Stop) {
setTimeout(this.stop, 0);
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
setTimeout(this.loadNext, 0);
} else if (newState.eventNow.endAction === EndAction.PlayNext) {
setTimeout(this.startNext, 0);
}
}
}
// update normal cycle
if (newState.clock - this.lastOnUpdate >= timerConfig.notificationRate) {
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
if (hasRunningTimer) {
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
integrationService.dispatch(TimerLifeCycle.onClock);
this.lastOnUpdate = newState.clock;
}
if (shouldCallRoll) {
// we dont call this.roll because we need to bypass the checks
const rundown = getPlayableEvents();
this.eventTimer.roll(rundown);
}
}
/** delay initialisation until we have a restore point */
init(resumable: RestorePoint | null) {
logger.info(LogOrigin.Server, 'Runtime service started');
// TODO: refresh at 32ms, slowing down now to keep UI responsive while we dont have granular updates
// calculate at 30fps, refresh at 1fps
this.eventTimer = new TimerService({ refresh: 1000, updateInterval: 1000 });
this.eventTimer = new TimerService({
refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate,
onUpdateCallback: this.checkTimerUpdate.bind(this),
});
if (resumable) {
this.resume(resumable);
@@ -94,14 +141,11 @@ class RuntimeService {
return foundNew;
}
reset() {
runtimeState.clear();
}
/**
* check whether underlying data of runtime has changed
* Called when the underlying data has changed,
* we check if the change affects the runtime
*/
update(affectedIds?: string[]) {
maybeUpdate(playableEvents: OntimeEvent[], affectedIds?: string[]) {
const state = runtimeState.getState();
const hasLoadedElements = state.eventNow || state.eventNext;
if (!hasLoadedElements) {
@@ -131,7 +175,6 @@ class RuntimeService {
isNext = this.isNewNext();
if (isNext) {
// TODO: do i need to load here?
const playableEvents = getPlayableEvents();
runtimeState.loadNext(playableEvents);
}
}
@@ -154,6 +197,7 @@ class RuntimeService {
const success = event.id === state.eventNow?.id;
if (success) {
integrationService.dispatch(TimerLifeCycle.onLoad);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
@@ -220,8 +264,7 @@ class RuntimeService {
if (!event) {
return false;
}
const success = this.loadEvent(event);
return success;
return this.loadEvent(event);
}
/**
@@ -234,8 +277,7 @@ class RuntimeService {
if (!event) {
return false;
}
const success = this.loadEvent(event);
return success;
return this.loadEvent(event);
}
/**
@@ -248,8 +290,7 @@ class RuntimeService {
if (!event) {
return false;
}
const success = this.loadEvent(event);
return success;
return this.loadEvent(event);
}
/**
@@ -260,8 +301,7 @@ class RuntimeService {
const state = runtimeState.getState();
const previousEvent = findPrevious(state.eventNow?.id);
if (previousEvent) {
const success = this.loadEvent(previousEvent);
return success;
return this.loadEvent(previousEvent);
}
return false;
}
@@ -274,8 +314,7 @@ class RuntimeService {
const state = runtimeState.getState();
const nextEvent = findNext(state.eventNow?.id);
if (nextEvent) {
const success = this.loadEvent(nextEvent);
return success;
return this.loadEvent(nextEvent);
}
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
@@ -288,9 +327,14 @@ class RuntimeService {
start() {
const state = runtimeState.getState();
const canStart = validatePlayback(state.timer.playback).start;
if (canStart) {
this.eventTimer.start();
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
if (!canStart) {
return false;
}
const didStart = this.eventTimer.start();
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
if (didStart) {
integrationService.dispatch(TimerLifeCycle.onStart);
}
}
@@ -299,9 +343,11 @@ class RuntimeService {
*/
startNext() {
const hasNext = this.loadNext();
if (hasNext) {
this.start();
if (!hasNext) {
return;
}
this.start();
}
/**
@@ -309,11 +355,14 @@ class RuntimeService {
*/
pause() {
const state = runtimeState.getState();
if (validatePlayback(state.timer.playback).pause) {
this.eventTimer.pause();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
const canPause = validatePlayback(state.timer.playback).pause;
if (!canPause) {
return;
}
this.eventTimer.pause();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
integrationService.dispatch(TimerLifeCycle.onPause);
}
/**
@@ -321,11 +370,14 @@ class RuntimeService {
*/
stop() {
const state = runtimeState.getState();
if (validatePlayback(state.timer.playback).stop) {
this.eventTimer.stop();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
const canStop = validatePlayback(state.timer.playback).stop;
if (!canStop) {
return;
}
this.eventTimer.stop();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
integrationService.dispatch(TimerLifeCycle.onStop);
}
/**
@@ -342,13 +394,20 @@ class RuntimeService {
* Sets playback to roll
*/
roll() {
const playableEvents = getPlayableEvents();
try {
this.eventTimer.roll(playableEvents);
} catch (error) {
logger.warning(LogOrigin.Server, `Roll: ${error}`);
const beforeState = runtimeState.getState();
const canRoll = validatePlayback(beforeState.timer.playback).roll;
if (!canRoll) {
return;
}
const playableEvents = getPlayableEvents();
if (playableEvents.length === 0) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
return;
}
this.eventTimer.roll(playableEvents);
const state = runtimeState.getState();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
+5 -7
View File
@@ -22,14 +22,12 @@ export const eventStore = {
},
set<T extends keyof RuntimeStore>(key: T, value: RuntimeStore[T]) {
store[key] = value;
// TODO: Partial updates seems to cause issues on the client
// socket.send({
// type: `ontime-${key}`,
// payload: value,
// });
this.broadcast();
socket.sendAsJson({
type: `ontime-${key}`,
payload: value,
});
},
batchSet<K extends keyof RuntimeStore>(values: Record<K, RuntimeStore[K]>) {
batchSet(values: Partial<RuntimeStore>) {
Object.entries(values).forEach(([key, value]) => {
store[key] = value;
});
@@ -35,7 +35,6 @@ const mockState = {
},
_timer: {
pausedAt: null,
lastUpdate: null,
secondaryTarget: null,
},
} as RuntimeState;
+27 -54
View File
@@ -1,9 +1,9 @@
import { Runtime, OntimeEvent, Playback, TimerState, TimerType, MaybeNumber } from 'ontime-types';
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { RestorePoint } from '../services/RestoreService.js';
import { getPlayableEvents } from '../services/rundown-service/RundownService.js';
import {
getCurrent,
getExpectedFinish,
@@ -48,7 +48,6 @@ export type RuntimeState = {
_timer: {
pausedAt: MaybeNumber;
finishedNow: boolean;
lastUpdate: MaybeNumber;
secondaryTarget: MaybeNumber;
};
};
@@ -63,7 +62,6 @@ const runtimeState: RuntimeState = {
timer: { ...initialTimer },
_timer: {
pausedAt: null,
lastUpdate: null,
secondaryTarget: null,
get finishedNow() {
return this.current <= 0 && this.finishedAt === null;
@@ -81,16 +79,18 @@ export function clear() {
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
runtimeState.runtime = { ...initialRuntime, actualStart: runtimeState.runtime.actualStart };
// TODO: can we cleanup the initialisation of runtime state?
runtimeState.runtime.numEvents = fetchNumEvents();
runtimeState.runtime = {
...initialRuntime,
// persist session stuff
actualStart: runtimeState.runtime.actualStart,
numEvents: runtimeState.runtime.numEvents,
};
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = clock.timeNow();
runtimeState.timer = { ...initialTimer };
runtimeState._timer = {
pausedAt: null,
lastUpdate: null,
secondaryTarget: null,
finishedNow: false,
};
@@ -108,18 +108,9 @@ function patchTimer(newState: Partial<TimerState>) {
}
}
/**
* Utility, getches number of events from EventLoader
* @param numEvents
*/
function fetchNumEvents(): number {
// TODO: could we avoid having this dependency?
return getPlayableEvents().length;
}
/**
* Utility, allows updating data derived from the rundown
* @param numEvents
* @param playableRundown
*/
export function updateRundownData(playableRundown: OntimeEvent[]) {
runtimeState.runtime.numEvents = playableRundown.length;
@@ -342,61 +333,43 @@ export function addTime(amount: number) {
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;
export type UpdateResult = {
hasTimerFinished: boolean;
shouldCallRoll: boolean;
};
export function update(): UpdateResult {
let hasTimerFinished = false;
let shouldCallRoll = false; // we also need to call roll if a secondary timer has finished
const previousTime = runtimeState.clock;
runtimeState.clock = clock.timeNow();
const hasSkippedBack = previousTime > runtimeState.clock;
if (hasSkippedBack) {
_force = true;
}
// update offset
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
// we call integrations if we update timers
if (runtimeState.timer.playback === Playback.Roll) {
const result = roll();
_shouldNotify = true;
_doRoll = result.doRoll;
_isFinished = result.isFinished;
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 = play();
_shouldNotify = true;
_isFinished = result.isFinished;
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;
}
// 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,
hasTimerFinished,
shouldCallRoll,
};
function roll() {
const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.timeSkipLimit);
function onRollUpdate() {
const hasSkippedOutOfEvent = skippedOutOfEvent(runtimeState, previousTime, timerConfig.skipLimit);
if (hasSkippedOutOfEvent) {
return { doRoll: true };
}
@@ -408,7 +381,7 @@ export function update(force: boolean, updateInterval: number) {
return { doRoll: doRollLoad, isFinished };
}
function play() {
function onPlayUpdate() {
let isFinished = false;
runtimeState.timer.current = getCurrent(runtimeState);
+3 -3
View File
@@ -1,6 +1,6 @@
export function isString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error(`Unexpected payload type: ${value}`);
throw new Error(`Unexpected payload type: ${String(value)}`);
}
}
@@ -12,12 +12,12 @@ export function isDefined<T>(value: T | undefined): asserts value is T {
export function isNumber(value: unknown): asserts value is number {
if (typeof value !== 'string') {
throw new Error(`Unexpected payload type: ${value}`);
throw new Error(`Unexpected payload type: ${String(value)}`);
}
}
export function isObject(value: unknown): asserts value is object {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`Unexpected payload type: ${value}`);
throw new Error(`Unexpected payload type: ${String(value)}`);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { ensureDirectory } from './fileManagement.js';
import { cellRequestFromEvent, getA1Notation } from './sheetUtils.js';
import { parseExcel } from './parser.js';
import { parseRundown, parseUserFields } from './parserFunctions.js';
import { getRundown } from '../services/rundown-service/RundownService.js';
import { getRundown } from '../services/rundown-service/rundownUtils.js';
type ResponseOK = {
data: Partial<DatabaseModel>;