diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index 87c7a8963..53042aca3 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -1,6 +1,6 @@ import isEqual from 'react-fast-compare'; import { Playback, RuntimeStore } from 'ontime-types'; -import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional' +import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional'; export const runtimeStorePlaceholder: RuntimeStore = { timer: { @@ -12,7 +12,6 @@ export const runtimeStorePlaceholder: RuntimeStore = { startedAt: null, finishedAt: null, secondaryTimer: null, - selectedEventId: null, duration: null, timerType: null, endAction: null, @@ -55,11 +54,12 @@ export const runtimeStorePlaceholder: RuntimeStore = { const deepCompare = (a: T, b: T) => isEqual(a, b); -export const runtime = createWithEqualityFn(() => ({ - ...runtimeStorePlaceholder, -}), deepCompare); +export const runtime = createWithEqualityFn( + () => ({ + ...runtimeStorePlaceholder, + }), + deepCompare, +); - -export const useRuntimeStore = ( - selector: (state: RuntimeStore) => T, -) => useStoreWithEqualityFn(runtime, selector, deepCompare); +export const useRuntimeStore = (selector: (state: RuntimeStore) => T) => + useStoreWithEqualityFn(runtime, selector, deepCompare); diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx index 1f6d49bf7..07ca16eae 100644 --- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx +++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx @@ -28,7 +28,6 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { const isPlaying = playback === Playback.Play; const isPaused = playback === Playback.Pause; const isArmed = playback === Playback.Armed; - const isStopped = playback === Playback.Stop; const isFirst = selectedEventIndex === 0; const isLast = selectedEventIndex === numEvents - 1; @@ -42,6 +41,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { const disablePause = !playbackCan.pause; const disableRoll = !playbackCan.roll || noEvents; const disableStop = !playbackCan.stop; + const disableReload = !playbackCan.reload; const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next'; const goModeAction = () => { @@ -83,7 +83,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { - + diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 7b53970af..862d9baa7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -31,19 +31,18 @@ import { DataProvider } from './classes/data-provider/DataProvider.js'; import { dbLoadingProcess } from './modules/loadDb.js'; // Services -import { eventTimer } from './services/TimerService.js'; -import { eventLoader } from './classes/event-loader/EventLoader.js'; +import { EventLoader } from './classes/event-loader/EventLoader.js'; import { integrationService } from './services/integration-service/IntegrationService.js'; import { logger } from './classes/Logger.js'; import { oscIntegration } from './services/integration-service/OscIntegration.js'; import { httpIntegration } from './services/integration-service/HttpIntegration.js'; import { populateStyles } from './modules/loadStyles.js'; import { eventStore } from './stores/EventStore.js'; -import { PlaybackService } from './services/PlaybackService.js'; +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 } from './state.js'; +import { state, stateMutations } from './state.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -155,46 +154,50 @@ export const startServer = async () => { expressServer = http.createServer(app); socket.init(expressServer); - eventLoader.init(); - - // load restore point if it exists - const maybeRestorePoint = await restoreService.load(); - if (maybeRestorePoint) { - logger.info(LogOrigin.Server, 'Found resumable state'); - PlaybackService.resume(maybeRestorePoint); - } - eventTimer.init(); /** * Module initialises the services and provides initial payload for the store * Currently registered objects in store - * - Timer Service timer - * - Timer Service playback + * * - Message Service timerMessage * - Message Service publicMessage * - Message Service lowerMessage - * - Message Service onAir - * - Event Loader loaded - * - Event Loader eventNow - * - Event Loader publicEventNow - * - Event Loader eventNext - * - Event Loader publicEventNext + * - Message Service externalMessage + * + * - Runtime Service onAir (derived from playback) + * - Runtime Service timer + * - Runtime Service playback + * - Runtime Service loaded // TODO: rename to runtime ?? + * - Runtime Service eventNow + * - Runtime Service publicEventNow + * - Runtime Service eventNext + * - Runtime Service publicEventNext */ eventStore.init({ - timer: state.timer, - playback: state.playback, timerMessage: messageService.timerMessage, publicMessage: messageService.publicMessage, lowerMessage: messageService.lowerMessage, externalMessage: messageService.externalMessage, onAir: state.playback !== Playback.Stop, - loaded: eventLoader.loaded, - eventNow: eventLoader.eventNow, - publicEventNow: eventLoader.publicEventNow, - eventNext: eventLoader.eventNext, - publicEventNext: eventLoader.publicEventNext, + timer: state.timer, + playback: state.playback, + loaded: state.runtime, + eventNow: state.eventNow, + publicEventNow: state.publicEventNow, + eventNext: state.eventNext, + publicEventNext: state.publicEventNext, }); + // load restore point if it exists + const maybeRestorePoint = await restoreService.load(); + + // TODO: pass event store to rundownservice + runtimeService.init(maybeRestorePoint); + + // TODO: do this on the init of the runtime service + const numEvents = EventLoader.getNumEvents(); + stateMutations.updateNumEvents(numEvents); + // eventStore set is a dependency of the services that publish to it messageService.init(eventStore.set.bind(eventStore)); @@ -277,7 +280,7 @@ export const shutdown = async (exitCode = 0) => { expressServer?.close(); oscServer?.shutdown(); - eventTimer.shutdown(); + runtimeService.shutdown(); integrationService.shutdown(); logger.shutdown(); socket.shutdown(); diff --git a/apps/server/src/classes/event-loader/EventLoader.ts b/apps/server/src/classes/event-loader/EventLoader.ts index f567472fa..3a1e15b44 100644 --- a/apps/server/src/classes/event-loader/EventLoader.ts +++ b/apps/server/src/classes/event-loader/EventLoader.ts @@ -1,53 +1,19 @@ -import { Loaded, OntimeEvent, SupportedEvent } from 'ontime-types'; +import { OntimeEvent, isOntimeEvent } from 'ontime-types'; import { DataProvider } from '../data-provider/DataProvider.js'; -import { getRollTimers } from '../../services/rollUtils.js'; -import { eventStore } from '../../stores/EventStore.js'; - -let instance; /** - * Manages business logic around loading events + * Manages business logic around loading and finding events */ export class EventLoader { - loaded: Loaded; - eventNow: OntimeEvent | null; - publicEventNow: OntimeEvent | null; - eventNext: OntimeEvent | null; - publicEventNext: OntimeEvent | null; - - constructor() { - if (instance) { - throw new Error('There can be only one'); - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton - instance = this; - this.eventNow = null; - this.publicEventNow = null; - this.eventNext = null; - this.publicEventNext = null; - this.loaded = { - selectedEventIndex: null, - selectedEventId: null, - selectedPublicEventId: null, - nextEventId: null, - nextPublicEventId: null, - numEvents: 0, - }; - } - - // we need to delay init until the store is ready - init() { - this.reset(false); - } + // TODO: migrate logic to RundownService /** * returns all events that contain time data * @return {array} */ static getTimedEvents(): OntimeEvent[] { - return DataProvider.getRundown().filter((event) => event.type === SupportedEvent.Event) as OntimeEvent[]; + return DataProvider.getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[]; } /** @@ -55,17 +21,15 @@ export class EventLoader { * @return {array} */ static getPlayableEvents(): OntimeEvent[] { - return DataProvider.getRundown().filter( - (event) => event.type === SupportedEvent.Event && !event.skip, - ) as OntimeEvent[]; + return DataProvider.getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; } /** * returns number of events * @return {number} */ - static getNumEvents() { - return EventLoader.getTimedEvents().length; + static getNumEvents(): number { + return EventLoader.getPlayableEvents().length; } /** @@ -75,7 +39,7 @@ export class EventLoader { */ static getEventAtIndex(eventIndex: number): OntimeEvent | undefined { const timedEvents = EventLoader.getTimedEvents(); - return timedEvents?.[eventIndex]; + return timedEvents.at(eventIndex); } /** @@ -83,7 +47,7 @@ export class EventLoader { * @param {string} eventId * @return {object | undefined} */ - static getEventWithId(eventId): OntimeEvent | undefined { + static getEventWithId(eventId: string): OntimeEvent | undefined { const timedEvents = EventLoader.getTimedEvents(); return timedEvents.find((event) => event.id === eventId); } @@ -93,255 +57,50 @@ export class EventLoader { * @param {string} cue * @return {object | undefined} */ - static getEventWithCue(cue) { + static getEventWithCue(cue: string): OntimeEvent | undefined { const timedEvents = EventLoader.getTimedEvents(); return timedEvents.find((event) => event.cue.toLowerCase() === cue.toLowerCase()); } - /** - * loads an event given its id - * @param {string} eventId - * @returns {{loadedEvent: null, selectedEventId: null, nextEventId: null, selectedPublicEventId: null, nextPublicEventId: null, numEvents: null, titles: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}, titlesPublic: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}, selectedEventIndex: null}} - */ - loadById(eventId) { - const event = EventLoader.getEventWithId(eventId); - return this.loadEvent(event); - } - - /** - * loads an event given its index - * @param {number} eventIndex - * @returns {{loadedEvent: null, selectedEventId: null, nextEventId: null, selectedPublicEventId: null, nextPublicEventId: null, numEvents: null, titles: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}, titlesPublic: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}, selectedEventIndex: null}} - */ - loadByIndex(eventIndex) { - const event = EventLoader.getEventAtIndex(eventIndex); - return this.loadEvent(event); - } - /** * finds the previous event * @return {object | undefined} */ - findPrevious() { + static findPrevious(currentEventId: string | null): OntimeEvent | null { const timedEvents = EventLoader.getPlayableEvents(); - if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === 0) { + if (!timedEvents || !timedEvents.length) { return null; } // if there is no event running, go to first - if (this.loaded.selectedEventIndex === null) { - return timedEvents[0]; + if (!currentEventId) { + return timedEvents.at(0); } - const newIndex = this.loaded.selectedEventIndex - 1; - return timedEvents?.[newIndex]; + const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); + const newIndex = Math.max(currentIndex - 1, 0); + const previousEvent = timedEvents.at(newIndex); + return previousEvent; } /** * finds the next event * @return {object | undefined} */ - findNext() { + static findNext(currentEventId: string | null): OntimeEvent | null { const timedEvents = EventLoader.getPlayableEvents(); - if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === this.loaded.numEvents - 1) { + if (!timedEvents || !timedEvents.length) { return null; } // if there is no event running, go to first - if (this.loaded.selectedEventIndex === null) { - return timedEvents[0]; - } - const newIndex = this.loaded.selectedEventIndex + 1; - return timedEvents?.[newIndex]; - } - - /** - * finds next event within Roll context - * @param {number} timeNow - current time in ms - */ - findRoll(timeNow) { - const timedEvents = EventLoader.getPlayableEvents(); - if (!timedEvents.length) { - return null; + if (!currentEventId) { + return timedEvents.at(0); } - const { nowIndex, timeToNext, nextEvent, nextPublicEvent, currentEvent, currentPublicEvent } = getRollTimers( - timedEvents, - timeNow, - ); - - // load events - this.eventNow = currentEvent; - this.publicEventNow = currentPublicEvent; - this.eventNext = nextEvent; - this.publicEventNext = nextPublicEvent; - - // loaded data summary - this.loaded.selectedEventIndex = nowIndex; - this.loaded.selectedEventId = currentEvent?.id || null; - this.loaded.numEvents = timedEvents.length; - this.loaded.nextEventId = nextEvent?.id || null; - this.loaded.nextPublicEventId = nextPublicEvent?.id || null; - - this._loadEvent(); - - return { currentEvent, nextEvent, timeToNext }; - } - - /** - * returns data for currently loaded event - * @returns {{loadedEvent: null, selectedEventId: (null|*), nextEventId: (null|*), selectedPublicEventId: (null|*), nextPublicEventId: (null|*), numEvents: (null|number|*), titles: (*|{presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}), titlesPublic: (*|{presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}), selectedEventIndex: (null|number|*)}} - */ - getLoaded() { - return { - loaded: this.loaded, - eventNow: this.eventNow, - publicEventNow: this.publicEventNow, - eventNext: this.eventNext, - publicEventNext: this.publicEventNext, - }; - } - - /** - * Forces event loader to update the event count - */ - updateNumEvents() { - this.loaded.numEvents = EventLoader.getPlayableEvents().length; - eventStore.set('loaded', this.loaded); - } - - /** - * Resets instance state - */ - reset(emit = true) { - this.eventNow = null; - this.publicEventNow = null; - this.eventNext = null; - this.publicEventNext = null; - this.loaded = { - selectedEventIndex: null, - selectedEventId: null, - selectedPublicEventId: null, - nextEventId: null, - nextPublicEventId: null, - numEvents: EventLoader.getPlayableEvents().length, - }; - - // workaround for socket not being ready in constructor - if (emit) { - this._loadEvent(); - } - } - - /** - * loads an event given its id - * @param {object} event - */ - loadEvent(event?: OntimeEvent) { - if (typeof event === 'undefined') { - return null; - } - const timedEvents = EventLoader.getPlayableEvents(); - const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id); - const playableEvents = EventLoader.getPlayableEvents(); - - // we know some stuff now - this.loaded.selectedEventIndex = eventIndex; - this.loaded.selectedEventId = event.id; - this.loaded.numEvents = timedEvents.length; - this.eventNow = event; - this._loadEventNow(event, playableEvents); - this._loadEventNext(playableEvents); - - this._loadEvent(); - - return this.getLoaded(); - } - - /** - * Handle side effects from event loading - */ - private _loadEvent() { - eventStore.batchSet({ - loaded: this.loaded, - eventNow: this.eventNow, - publicEventNow: this.publicEventNow, - eventNext: this.eventNext, - publicEventNext: this.publicEventNext, - }); - } - - /** - * @description loads currently running events - * @private - * @param {object} event - * @param {array} rundown - */ - private _loadEventNow(event, rundown) { - this.eventNow = event; - - // check if current is also public - if (event.isPublic) { - this.publicEventNow = event; - this.loaded.selectedPublicEventId = event.id; - } else { - // assume there is no public event - this.publicEventNow = null; - this.loaded.selectedPublicEventId = null; - - // if there is nothing before, return - if (this.loaded.selectedEventIndex === 0) return; - - // iterate backwards to find it - for (let i = this.loaded.selectedEventIndex; i >= 0; i--) { - if (rundown[i].isPublic) { - this.publicEventNow = rundown[i]; - this.loaded.selectedPublicEventId = rundown[i].id; - break; - } - } - } - } - - /** - * @description look for next events - * @private - */ - private _loadEventNext(rundown) { - // assume there are no next events - this.eventNext = null; - this.publicEventNext = null; - this.loaded.nextEventId = null; - this.loaded.nextPublicEventId = null; - - if (this.loaded.selectedEventIndex === null) return; - - const numEvents = rundown.length; - - if (this.loaded.selectedEventIndex < numEvents - 1) { - let nextPublic = false; - let nextProduction = false; - - for (let i = this.loaded.selectedEventIndex + 1; i < numEvents; i++) { - // if we have not set private - if (!nextProduction) { - this.eventNext = rundown[i]; - this.loaded.nextEventId = rundown[i].id; - nextProduction = true; - } - - // if event is public - if (rundown[i].isPublic) { - this.publicEventNext = rundown[i]; - this.loaded.nextPublicEventId = rundown[i].id; - nextPublic = true; - } - - // Stop if both are set - if (nextPublic && nextProduction) break; - } - } + const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); + const newIndex = (currentIndex + 1) % timedEvents.length; + const nextEvent = timedEvents.at(newIndex); + return nextEvent; } } - -export const eventLoader = new EventLoader(); diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index 105745013..84a7e53ed 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -3,7 +3,7 @@ import * as assert from '../utils/assert.js'; import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; import { isPartialTimerMessage, messageService } from '../services/message-service/MessageService.js'; -import { PlaybackService } from '../services/PlaybackService.js'; +import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { eventStore } from '../stores/EventStore.js'; import { parse, updateEvent } from './integrationController.config.js'; @@ -122,12 +122,12 @@ const actionHandlers: Record = { } } - PlaybackService.start(); + runtimeService.start(); return { payload: 'start' }; }, 'start-next': () => { - PlaybackService.startNext(); + runtimeService.startNext(); return { payload: 'start' }; }, startindex: (payload) => { @@ -137,7 +137,7 @@ const actionHandlers: Record = { } // Indexes in frontend are 1 based - const success = PlaybackService.startByIndex(eventIndex - 1); + const success = runtimeService.startByIndex(eventIndex - 1); if (!success) { throw new Error(`Event index not recognised or out of range ${eventIndex}`); } @@ -145,36 +145,36 @@ const actionHandlers: Record = { }, startid: (payload) => { assert.isString(payload); - PlaybackService.startById(payload); + runtimeService.startById(payload); return { payload: 'success' }; }, startcue: (payload) => { assert.isString(payload); - PlaybackService.startByCue(payload); + runtimeService.startByCue(payload); return { payload: 'success' }; }, pause: () => { - PlaybackService.pause(); + runtimeService.pause(); return { payload: 'success' }; }, previous: () => { - PlaybackService.loadPrevious(); + runtimeService.loadPrevious(); return { payload: 'success' }; }, next: () => { - PlaybackService.loadNext(); + runtimeService.loadNext(); return { payload: 'success' }; }, stop: () => { - PlaybackService.stop(); + runtimeService.stop(); return { payload: 'success' }; }, reload: () => { - PlaybackService.reload(); + runtimeService.reload(); return { payload: 'success' }; }, roll: () => { - PlaybackService.roll(); + runtimeService.roll(); return { payload: 'success' }; }, loadindex: (payload) => { @@ -183,22 +183,25 @@ const actionHandlers: Record = { throw new Error(`Event index out of range ${eventIndex}`); } // Indexes in frontend are 1 based - PlaybackService.loadByIndex(eventIndex - 1); + runtimeService.loadByIndex(eventIndex - 1); return { payload: 'success' }; }, loadid: (payload) => { assert.isDefined(payload); - PlaybackService.loadById(payload.toString().toLowerCase()); + runtimeService.loadById(payload.toString().toLowerCase()); return { payload: 'success' }; }, loadcue: (payload) => { assert.isString(payload); - PlaybackService.loadByCue(payload); + runtimeService.loadByCue(payload); return { payload: 'success' }; }, addtime: (payload) => { const time = numberOrError(payload); - PlaybackService.addTime(time); + if (time === 0) { + return { payload: 'success' }; + } + runtimeService.addTime(time * 1000); return { payload: 'success' }; }, }; diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 1de3f64b7..df94ef1bf 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -18,7 +18,7 @@ import { copyFile, rename, writeFile } from 'fs/promises'; import { fileHandler } from '../utils/parser.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; -import { PlaybackService } from '../services/PlaybackService.js'; +import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { eventStore } from '../stores/EventStore.js'; import { getAppDataPath, @@ -98,7 +98,7 @@ async function parseFile(file, _req, _res, options) { const parseAndApply = async (file, _req, res, options) => { const result = await parseFile(file, _req, res, options); - PlaybackService.stop(); + runtimeService.stop(); const newRundown = result.rundown || []; if (options?.onlyRundown === 'true') { @@ -409,7 +409,7 @@ export async function patchPartialProjectFile(req, res) { await DataProvider.mergeIntoData(patchDb); if (patchDb.rundown !== undefined) { // it is likely cheaper to invalidate cache than to calculate diff - PlaybackService.stop(); + runtimeService.stop(); runtimeCacheStore.invalidate(delayedRundownCacheKey); notifyChanges({ external: true, reset: true }); } diff --git a/apps/server/src/services/Clock.ts b/apps/server/src/services/Clock.ts index 4270f5f08..c19bad194 100644 --- a/apps/server/src/services/Clock.ts +++ b/apps/server/src/services/Clock.ts @@ -3,6 +3,9 @@ enum Source { MIDI = 'MIDI', } +/** + * Service manages retrieving current time from a managed time source + */ class Clock { private static instance: Clock; private readonly source: Source; diff --git a/apps/server/src/services/ConfigService.ts b/apps/server/src/services/ConfigService.ts index 88482796f..e85adb935 100644 --- a/apps/server/src/services/ConfigService.ts +++ b/apps/server/src/services/ConfigService.ts @@ -8,6 +8,10 @@ interface Config { lastLoadedProject: string; } +/** + * Service manages Ontime's runtime configuration + */ + class ConfigService { private config: Low; private configPath: string; diff --git a/apps/server/src/services/PlaybackService.ts b/apps/server/src/services/PlaybackService.ts deleted file mode 100644 index 60051531e..000000000 --- a/apps/server/src/services/PlaybackService.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; -import { validatePlayback } from 'ontime-utils'; - -import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js'; -import { eventStore } from '../stores/EventStore.js'; -import { eventTimer } from './TimerService.js'; -import { clock } from './Clock.js'; -import { logger } from '../classes/Logger.js'; -import { RestorePoint } from './RestoreService.js'; -import { state } from '../state.js'; - -/** - * Service manages playback status of app - * Coordinating with necessary services - */ -export class PlaybackService { - /** - * makes calls for loading and starting given event - * @param {OntimeEvent} event - * @return {boolean} success - */ - static loadEvent(event: OntimeEvent): boolean { - let success = false; - if (!event) { - logger.error(LogOrigin.Playback, 'No event found'); - } else if (event.skip) { - logger.warning(LogOrigin.Playback, `Refused playback of skipped event ID ${event.id}`); - } else { - eventLoader.loadEvent(event); - eventTimer.load(event); - success = true; - } - eventStore.broadcast(); - return success; - } - - /** - * starts event matching given ID - * @param {string} eventId - * @return {boolean} success - */ - static startById(eventId: string): boolean { - const event = EventLoader.getEventWithId(eventId); - const success = PlaybackService.loadEvent(event); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - PlaybackService.start(); - } - return success; - } - - /** - * starts an event at index - * @param {number} eventIndex - * @return {boolean} success - */ - static startByIndex(eventIndex: number): boolean { - const event = EventLoader.getEventAtIndex(eventIndex); - const success = PlaybackService.loadEvent(event); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - PlaybackService.start(); - } - return success; - } - - /** - * starts first event matching given cue - * @param {string} cue - * @return {boolean} success - */ - static startByCue(cue: string): boolean { - const event = EventLoader.getEventWithCue(cue); - const success = PlaybackService.loadEvent(event); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - PlaybackService.start(); - } - return success; - } - - /** - * loads event matching given ID - * @param {string} eventId - * @return {boolean} success - */ - static loadById(eventId: string): boolean { - const event = EventLoader.getEventWithId(eventId); - const success = PlaybackService.loadEvent(event); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - } - return success; - } - - /** - * loads event matching given ID - * @param {number} eventIndex - * @return {boolean} success - */ - static loadByIndex(eventIndex: number): boolean { - const event = EventLoader.getEventAtIndex(eventIndex); - const success = PlaybackService.loadEvent(event); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - } - return success; - } - - /** - * loads first event matching given cue - * @param {string} cue - * @return {boolean} success - */ - static loadByCue(cue: string): boolean { - const event = EventLoader.getEventWithCue(cue); - const success = PlaybackService.loadEvent(event); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - } - return success; - } - - /** - * Loads event before currently selected - */ - static loadPrevious() { - const previousEvent = eventLoader.findPrevious(); - if (previousEvent) { - const success = PlaybackService.loadEvent(previousEvent); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${previousEvent.id}`); - } - } - } - - /** - * Loads event after currently selected - * @param {string} [fallbackAction] - 'stop', 'pause' - * @return {boolean} success - */ - static loadNext(fallbackAction?: 'stop' | 'pause'): boolean { - const nextEvent = eventLoader.findNext(); - if (nextEvent) { - const success = PlaybackService.loadEvent(nextEvent); - if (success) { - logger.info(LogOrigin.Playback, `Loaded event with ID ${nextEvent.id}`); - return true; - } - } else if (fallbackAction === 'stop') { - logger.info(LogOrigin.Playback, 'No next event found! Stopping playback'); - PlaybackService.stop(); - return false; - } else if (fallbackAction === 'pause') { - logger.info(LogOrigin.Playback, 'No next event found! Pausing playback'); - PlaybackService.pause(); - return false; - } else { - logger.info(LogOrigin.Playback, 'No next event found! Continuing playback'); - return false; - } - } - - /** - * Starts playback on selected event - */ - static start() { - if (validatePlayback(state.playback).start) { - eventTimer.start(); - const newState = state.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); - } - } - - /** - * Starts playback on next event - * @param {string} [fallbackAction] - 'stop', 'pause' - */ - static startNext(fallbackAction?: 'stop' | 'pause') { - const success = PlaybackService.loadNext(fallbackAction); - if (success) { - PlaybackService.start(); - } - } - - /** - * Pauses playback on selected event - */ - static pause() { - if (validatePlayback(state.playback).pause) { - eventTimer.pause(); - const newState = state.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); - } - } - - /** - * Stops timer and unloads any events - */ - static stop() { - if (validatePlayback(state.playback).stop) { - eventLoader.reset(); - eventTimer.stop(); - const newState = state.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); - } - } - - /** - * Reloads current event - */ - static reload() { - if (state.timer.selectedEventId) { - this.loadById(state.timer.selectedEventId); - } - } - - /** - * Sets playback to roll - */ - static roll() { - if (EventLoader.getPlayableEvents()) { - const rollTimers = eventLoader.findRoll(clock.timeNow()); - - // nothing to play - if (rollTimers === null) { - logger.warning(LogOrigin.Server, 'Roll: no events found'); - PlaybackService.stop(); - return; - } - - const { currentEvent, nextEvent } = rollTimers; - if (!currentEvent && !nextEvent) { - logger.warning(LogOrigin.Server, 'Roll: no events found'); - PlaybackService.stop(); - return; - } - - eventTimer.roll(currentEvent, nextEvent); - - const newState = state.playback; - logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); - } - } - - /** - * @description resume playback state given a restore point - * @param restorePoint - */ - static resume(restorePoint: RestorePoint) { - const willResume = () => logger.info(LogOrigin.Server, 'Resuming playback'); - - if (restorePoint.playback === Playback.Roll) { - willResume(); - PlaybackService.roll(); - } - - if (restorePoint.selectedEventId) { - const event = EventLoader.getEventWithId(restorePoint.selectedEventId); - // the db would have to change for the event not to exist - // we do not kow the reason for the crash, so we check anyway - if (!event) { - return; - } - - eventLoader.loadEvent(event); - eventTimer.resume(event, restorePoint); - eventStore.broadcast(); - return; - } - } - - /** - * Adds time to current event - * @param {number} time - time to add in seconds - */ - static addTime(time: number) { - if (state.timer.selectedEventId) { - const timeInMs = time * 1000; - eventTimer.addTime(timeInMs); - timeInMs > 0 - ? logger.info(LogOrigin.Playback, `Added ${time} sec`) - : logger.info(LogOrigin.Playback, `Removed ${time} sec`); - } - } - - /** - * Adds delay to current event - * @deprecated Use addTime - * @param {number} delayTime time in minutes - */ - static setDelay(delayTime: number) { - this.addTime(delayTime * 60); - } -} diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 8ea92605c..fd542a33a 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,15 +1,10 @@ -import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; +import { OntimeEvent, Playback } from 'ontime-types'; -import { logger } from '../classes/Logger.js'; -import type { RestorePoint } from './RestoreService.js'; import { stateMutations, state } from '../state.js'; -type initialLoadingData = { - startedAt?: number | null; - expectedFinish?: number | null; - current?: number | null; -}; - +/** + * Service manages Ontime's main timer + */ export class TimerService { private _interval: NodeJS.Timer; private _updateInterval: number; @@ -17,82 +12,17 @@ export class TimerService { /** * @constructor - * @param {object} [timerConfig] * @param {number} [timerConfig.refresh] * @param {number} [timerConfig.updateInterval] */ constructor(timerConfig: { refresh: number; updateInterval: number }) { this._refreshInterval = timerConfig.refresh; this._updateInterval = timerConfig.updateInterval; - logger.info(LogOrigin.Server, 'Timer service started'); - } - - init() { - this._interval = setInterval(() => this.update(), this._refreshInterval); - } - - /** - * Resumes a given playback state, same as load - * @param {RestorePoint} restorePoint - * @param {OntimeEvent} event - */ - resume(event: OntimeEvent, restorePoint: RestorePoint) { - stateMutations.timer.resume(event, restorePoint); - } - - // TODO: can load and hotreload be merged? - /** - * Reloads information for currently running timer - * @param event - */ - hotReload(event: OntimeEvent | undefined) { - if (event === undefined) { - this.stop(); - return; - } - - // TODO: this is no longer correct - if (event.id !== state.timer.selectedEventId) { - // we only hot reload if the timer is the same - return; - } - - if (event.skip) { - this.stop(); - } - - // TODO: check if any relevant information warrants update - stateMutations.timer.reload(event); - - this.update(true); - } - - /** - * Loads given timer to object - * @param {OntimeEvent} event - * @param {initialLoadingData} initialData - */ - load(event: OntimeEvent, initialData?: initialLoadingData) { - if (event.skip) { - throw new Error('Refuse load of skipped event'); - } - - stateMutations.timer.clear(); - - // TODO: does this replace the need for hot reload? - if (initialData) { - stateMutations.timer.patch(initialData); - } - - stateMutations.timer.load(event); + this._interval = setInterval(this.update, 32); } start() { - if (!state.timer.selectedEventId) { - // TODO: we should be able to start - if (state.playback === Playback.Roll) { - logger.error(LogOrigin.Playback, 'Cannot start while waiting for event'); - } + if (!state.runtime.selectedEventId) { return; } @@ -100,6 +30,8 @@ export class TimerService { 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(); } @@ -122,33 +54,34 @@ export class TimerService { * @param {number} amount */ addTime(amount: number) { - if (amount === 0) { - return; - } - if (state.timer.selectedEventId === null) { + if (state.runtime.selectedEventId === null) { return; } stateMutations.timer.addTime(amount); } + /** + * Update the app at regular intervals + * @param {boolean} force whether we should force a broadcast of state + */ update(force = false) { stateMutations.timer.update(force, this._updateInterval); } /** * Loads roll information into timer service - * @param {OntimeEvent | null} currentEvent -- both current event and next event cant be null - * @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null + * @throws {Error} if rundown is empty + * @param {OntimeEvent[]} rundown -- list of events to run */ - roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) { - stateMutations.timer.roll(currentEvent, nextEvent); - this.update(); + roll(rundown: OntimeEvent[]) { + if (rundown.length === 0) { + throw new Error('No events found'); + } + + stateMutations.timer.roll(rundown); } shutdown() { clearInterval(this._interval); } } - -// calculate at 30fps, refresh at 1fps -export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 }); diff --git a/apps/server/src/services/__tests__/rollUtils.test.ts b/apps/server/src/services/__tests__/rollUtils.test.ts deleted file mode 100644 index 329ece996..000000000 --- a/apps/server/src/services/__tests__/rollUtils.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -import { OntimeEvent } from 'ontime-types'; -import { dayInMs } from 'ontime-utils'; - -import { getRollTimers, normaliseEndTime, sortArrayByProperty, updateRoll } from '../rollUtils.js'; - -// test sortArrayByProperty() -describe('sort simple arrays of objects', () => { - it('sort array 1-5', () => { - const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }]; - - const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }]; - - const sorted = sortArrayByProperty(arr1, 'timeStart'); - expect(sorted).toStrictEqual(arr1Expected); - }); - - it('sort array 1-5 with null', () => { - const arr1 = [ - { timeStart: 1 }, - { timeStart: 5 }, - { timeStart: 3 }, - { timeStart: 2 }, - { timeStart: 4 }, - { timeStart: null }, - ]; - - const arr1Expected = [ - { timeStart: null }, - { timeStart: 1 }, - { timeStart: 2 }, - { timeStart: 3 }, - { timeStart: 4 }, - { timeStart: 5 }, - ]; - - const sorted = sortArrayByProperty(arr1, 'timeStart'); - expect(sorted).toStrictEqual(arr1Expected); - }); -}); - -// test getRollTimers() -describe('test that roll loads selection in right order', () => { - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 5, - timeEnd: 10, - isPublic: false, - }, - { - id: '2', - timeStart: 10, - timeEnd: 20, - isPublic: false, - }, - { - id: '3', - timeStart: 20, - timeEnd: 30, - isPublic: false, - }, - { - id: '4', - timeStart: 30, - timeEnd: 40, - isPublic: false, - }, - { - id: '5', - timeStart: 40, - timeEnd: 50, - isPublic: true, - }, - { - id: '6', - timeStart: 50, - timeEnd: 60, - isPublic: false, - }, - { - id: '7', - timeStart: 60, - timeEnd: 70, - isPublic: true, - }, - { - id: '8', - timeStart: 70, - timeEnd: 80, - isPublic: false, - }, - ]; - - it('if timer is at 0', () => { - const now = 0; - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 4, - timeToNext: 5, - nextEvent: eventlist[0], - nextPublicEvent: eventlist[4], - currentEvent: null, - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 5', () => { - const now = 5; - const expected = { - nowIndex: 0, - nowId: eventlist[0].id, - publicIndex: null, - nextIndex: 1, - publicNextIndex: 4, - timeToNext: 5, - nextEvent: eventlist[1], - nextPublicEvent: eventlist[4], - currentEvent: eventlist[0], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 15', () => { - const now = 15; - const expected = { - nowIndex: 1, - nowId: eventlist[1].id, - publicIndex: null, - nextIndex: 2, - publicNextIndex: 4, - timeToNext: 5, - nextEvent: eventlist[2], - nextPublicEvent: eventlist[4], - currentEvent: eventlist[1], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 20', () => { - const now = 20; - const expected = { - nowIndex: 2, - nowId: eventlist[2].id, - publicIndex: null, - nextIndex: 3, - publicNextIndex: 4, - timeToNext: 10, - nextEvent: eventlist[3], - nextPublicEvent: eventlist[4], - currentEvent: eventlist[2], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 49', () => { - const now = 49; - const expected = { - nowIndex: 4, - nowId: eventlist[4].id, - publicIndex: 4, - nextIndex: 5, - publicNextIndex: 6, - timeToNext: 1, - nextEvent: eventlist[5], - nextPublicEvent: eventlist[6], - currentEvent: eventlist[4], - currentPublicEvent: eventlist[4], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 63', () => { - const now = 63; - const expected = { - nowIndex: 6, - nowId: eventlist[6].id, - publicIndex: 6, - nextIndex: 7, - publicNextIndex: null, - timeToNext: 7, - nextEvent: eventlist[7], - nextPublicEvent: null, - currentEvent: eventlist[6], - currentPublicEvent: eventlist[6], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 75', () => { - const now = 75; - const expected = { - nowIndex: 7, - nowId: eventlist[7].id, - publicIndex: 6, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[7], - currentPublicEvent: eventlist[6], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 100 we roll to day after', () => { - const now = 100; - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 4, - timeToNext: dayInMs - now + eventlist[0].timeStart, - nextEvent: eventlist[0], - nextPublicEvent: eventlist[4], - currentEvent: null, - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('handles rolls to next day with real values', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 36000000, // 10:00 - timeEnd: 39600000, // 11:00 - isPublic: true, - }, - ]; - const now = 64800000; // 18:00 - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 0, - timeToNext: dayInMs - now + singleEventList[0].timeStart, - nextEvent: singleEventList[0], - nextPublicEvent: singleEventList[0], - currentEvent: null, - currentPublicEvent: null, - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('handles rolls to next day with real values', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 36000000, // 10:00 - timeEnd: 3600000, // 01:00 - isPublic: true, - }, - ]; - const now = 60000; // 00:01 - const expected = { - nowIndex: 0, - nowId: singleEventList[0].id, - publicIndex: 0, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: singleEventList[0], - currentPublicEvent: singleEventList[0], - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - it('handles rolls to next day with real values', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 36000000, // 10:00 - timeEnd: 3600000, // 01:00 - isPublic: true, - }, - ]; - const now = 60000; // 00:01 - const expected = { - nowIndex: 0, - nowId: singleEventList[0].id, - publicIndex: 0, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: singleEventList[0], - currentPublicEvent: singleEventList[0], - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('handles roll that goes over midnight', () => { - const singleEventList: Partial[] = [ - { - id: '1', - timeStart: 72000000, // 20:00 - timeEnd: 60000, // 00:10 - isPublic: true, - }, - ]; - const now = 6000; // 00:01 - const expected = { - nowIndex: 0, - nowId: singleEventList[0].id, - publicIndex: 0, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: singleEventList[0], - currentPublicEvent: singleEventList[0], - }; - const state = getRollTimers(singleEventList as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); -}); - -// test getRollTimers() -describe('test that roll behaviour with overlapping times', () => { - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 10, - timeEnd: 10, - isPublic: false, - }, - { - id: '2', - timeStart: 10, - timeEnd: 20, - isPublic: true, - }, - { - id: '3', - timeStart: 10, - timeEnd: 30, - isPublic: false, - }, - ]; - - it('if timer is at 0', () => { - const now = 0; - const expected = { - nowIndex: null, - nowId: null, - publicIndex: null, - nextIndex: 0, - publicNextIndex: 1, - timeToNext: 10, - nextEvent: eventlist[0], - nextPublicEvent: eventlist[1], - currentEvent: null, - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 10', () => { - const now = 10; - const expected = { - nowIndex: 1, - nowId: eventlist[1].id, - publicIndex: 1, - nextIndex: 2, - publicNextIndex: null, - timeToNext: 0, - nextEvent: eventlist[2], - nextPublicEvent: null, - currentEvent: eventlist[1], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 15', () => { - const now = 15; - const expected = { - nowIndex: 1, - nowId: eventlist[1].id, - publicIndex: 1, - nextIndex: 2, - publicNextIndex: null, - timeToNext: -5, - nextEvent: eventlist[2], - nextPublicEvent: null, - currentEvent: eventlist[1], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 20', () => { - const now = 20; - const expected = { - nowIndex: 2, - nowId: eventlist[2].id, - publicIndex: 1, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[2], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if timer is at 25', () => { - const now = 25; - const expected = { - nowIndex: 2, - nowId: eventlist[2].id, - publicIndex: 1, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[2], - currentPublicEvent: eventlist[1], - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); -}); - -// test getRollTimers() on issue #58 -describe('test that roll behaviour multi day event edge cases', () => { - it('if the start time is the day after end time, and start time is earlier than now', () => { - const now = 66600000; // 19:30 - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 66000000, // 19:20 - timeEnd: 54600000, // 16:10 - isPublic: false, - }, - ]; - const expected = { - nowIndex: 0, - nowId: '1', - publicIndex: null, - nextIndex: null, - publicNextIndex: null, - timeToNext: null, - nextEvent: null, - nextPublicEvent: null, - currentEvent: eventlist[0], - currentPublicEvent: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); - - it('if the start time is the day after end time, and both are later than now', () => { - const now = 66840000; // 19:34 - const eventlist: Partial[] = [ - { - id: '1', - timeStart: 67200000, // 19:40 - timeEnd: 66900000, // 19:35 - isPublic: false, - }, - ]; - const expected = { - currentEvent: { - id: '1', - isPublic: false, - timeEnd: 66900000, - timeStart: 67200000, - }, - currentPublicEvent: null, - nextEvent: null, - nextIndex: null, - nextPublicEvent: null, - nowId: '1', - nowIndex: 0, - publicIndex: null, - publicNextIndex: null, - timeToNext: null, - }; - - const state = getRollTimers(eventlist as OntimeEvent[], now); - expect(state).toStrictEqual(expected); - }); -}); - -// test normaliseEndTime() on issue #58 -test('test typical scenarios', () => { - const t1 = { - start: 10, - end: 20, - }; - const t1_expected = 20; - - expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected); - - const t2 = { - start: 10 + dayInMs, - end: 20, - }; - const t2_expected = 20 + dayInMs; - - expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected); - - const t3 = { - start: 10, - end: 10, - }; - const t3_expected = 10; - - expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected); -}); - -// test updateRoll() -describe('typical scenarios', () => { - it('it updates running events correctly', () => { - const timers = { - selectedEventId: '1', - current: 10, - _finishAt: 15, - clock: 11, - secondaryTimer: null, - secondaryTarget: null, - }; - - const expected = { - updatedTimer: timers._finishAt - timers.clock, - updatedSecondaryTimer: null, - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - - // test that it can jump time - timers._finishAt = 1000; - timers.clock = 600; - expected.updatedTimer = timers._finishAt - timers.clock; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('it updates secondary timer', () => { - const timers = { - selectedEventId: null, - current: null, - _finishAt: null, - clock: 11, - secondaryTimer: 1, - secondaryTarget: 15, - }; - - const expected = { - updatedTimer: null, - updatedSecondaryTimer: timers.secondaryTarget - timers.clock, - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('flags an event end', () => { - const timers = { - selectedEventId: '1', - current: 10, - _finishAt: 11, - clock: 12, - secondaryTimer: null, - secondaryTarget: null, - }; - - 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 = { - selectedEventId: null, - current: null, - _finishAt: null, - clock: 16, - secondaryTimer: 1, - secondaryTarget: 15, - }; - - const expected = { - updatedTimer: null, - updatedSecondaryTimer: timers.secondaryTarget - timers.clock, - doRollLoad: true, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('when a secondary timer is finished, it prompts for new event load', () => { - const timers = { - selectedEventId: null, - current: null, - _finishAt: null, - clock: 15, - secondaryTimer: 0, - secondaryTarget: 15, - }; - - const expected = { - updatedTimer: null, - updatedSecondaryTimer: timers.secondaryTarget - timers.clock, - doRollLoad: true, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('counts over midnight', () => { - const timers = { - selectedEventId: '1', - current: 25, - _finishAt: 10 + dayInMs, - clock: dayInMs - 10, - secondaryTimer: null, - secondaryTarget: null, - }; - - const expected = { - updatedTimer: 20, - updatedSecondaryTimer: null, - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); - - it('rolls over midnight', () => { - const timers = { - selectedEventId: '1', - current: dayInMs, - _finishAt: 10 + dayInMs, - clock: 10, - secondaryTimer: null, - secondaryTarget: null, - }; - - const expected = { - updatedTimer: dayInMs, - updatedSecondaryTimer: null, - doRollLoad: false, - isFinished: false, - }; - - expect(updateRoll(timers)).toStrictEqual(expected); - }); -}); diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index acb17cc1a..b68578103 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,328 +1,413 @@ import { dayInMs } from 'ontime-utils'; -import { TimerType } from 'ontime-types'; +import { OntimeEvent, TimerType } from 'ontime-types'; -import { getCurrent, getExpectedFinish, skippedOutOfEvent } from '../timerUtils.js'; +import { + getCurrent, + getExpectedFinish, + getRollTimers, + normaliseEndTime, + skippedOutOfEvent, + updateRoll, +} from '../timerUtils.js'; +import { TState } from '../../state.js'; describe('getExpectedFinish()', () => { it('is null if we havent started', () => { - const startedAt = null; - const finishedAt = null; - const duration = 10; - const pausedTime = 0; - const addedTime = 0; - const endTime = 10; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 10, + }, + timer: { + addedTime: 0, + duration: 10, + finishedAt: null, + pausedAt: null, + startedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(null); }); it('is finishedAt if defined', () => { - const startedAt = 10; - const finishedAt = 20; - const duration = 10; - const pausedTime = 0; - const addedTime = 0; - const endTime = 20; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); - expect(calculatedFinish).toBe(finishedAt); + const state = { + eventNow: { + timeEnd: 20, + }, + timer: { + addedTime: 0, + duration: 10, + finishedAt: 20, // <---- finished at + pausedAt: null, + startedAt: 10, + timerType: TimerType.CountDown, + }, + } as TState; + const calculatedFinish = getExpectedFinish(state); + expect(calculatedFinish).toBe(20); }); it('calculates the finish time', () => { - const startedAt = 1; - const finishedAt = null; - const duration = 10; - const pausedTime = 0; - const addedTime = 0; - const endTime = 11; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 11, + }, + timer: { + addedTime: 0, + duration: 10, + finishedAt: null, + pausedAt: null, + startedAt: 1, + timerType: TimerType.CountDown, + }, + } as TState; + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(11); }); it('adds paused and added times', () => { - const startedAt = 1; - const finishedAt = null; - const duration = 10; - const pausedTime = 10; - const addedTime = 10; - const endTime = 11; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 11, + }, + timer: { + addedTime: 20, + duration: 10, + finishedAt: null, + pausedAt: null, + startedAt: 1, + timerType: TimerType.CountDown, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(31); }); it('added time could be negative', () => { - const startedAt = 1; - const finishedAt = null; - const duration = 10; - const pausedTime = 10; - const addedTime = -10; - const endTime = 11; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); - expect(calculatedFinish).toBe(11); + const state = { + eventNow: { + timeEnd: 11, + }, + timer: { + addedTime: -10, + duration: 10, + finishedAt: null, + pausedAt: null, + startedAt: 1, + timerType: TimerType.CountDown, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); + expect(calculatedFinish).toBe(1); }); it('user could add enough time for it to be negative', () => { - const startedAt = 1; - const finishedAt = null; - const duration = 10; - const pausedTime = 0; - const addedTime = -100; - const endTime = 11; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 11, + }, + timer: { + addedTime: -100, + duration: 10, + finishedAt: null, + pausedAt: null, + startedAt: 1, + timerType: TimerType.CountDown, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(1); }); it('timer can have no duration', () => { - const startedAt = 1; - const finishedAt = null; - const duration = 0; - const pausedTime = 0; - const addedTime = 0; - const endTime = 0; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 0, + }, + timer: { + addedTime: 0, + duration: 0, + finishedAt: null, + pausedAt: null, + startedAt: 1, + timerType: TimerType.CountDown, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(1); }); it('finish can be the day after', () => { - const startedAt = 10; - const finishedAt = null; - const duration = dayInMs; - const pausedTime = 0; - const addedTime = 0; - const endTime = 10; - const timerType = TimerType.CountDown; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 10, + }, + timer: { + addedTime: 0, + duration: dayInMs, + finishedAt: null, + pausedAt: null, + startedAt: 10, + timerType: TimerType.CountDown, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(10); }); describe('on timers of type time-to-end', () => { it('finish time is as schedule + added time', () => { - const startedAt = 10; - const finishedAt = null; - const duration = dayInMs; - const pausedTime = 0; - const addedTime = 10; - const endTime = 30; - const timerType = TimerType.TimeToEnd; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); + const state = { + eventNow: { + timeEnd: 30, + }, + timer: { + addedTime: 10, + duration: dayInMs, + finishedAt: null, + pausedAt: null, + startedAt: 10, + timerType: TimerType.TimeToEnd, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); expect(calculatedFinish).toBe(40); }); it('handles events that finish the day after', () => { - const startedAt = 79200000; // 22:00:00 - const finishedAt = null; - const duration = Infinity; // not relevant - const pausedTime = 0; - const addedTime = 0; - const endTime = 600000; // 00:10:00 - const timerType = TimerType.TimeToEnd; - const calculatedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); - // expected finish is not a duration but a timetag + const state = { + eventNow: { + timeEnd: 600000, // 00:10:00 + }, + timer: { + addedTime: 0, + finishedAt: null, + pausedAt: null, + startedAt: 79200000, // 22:00:00 + timerType: TimerType.TimeToEnd, + }, + } as TState; + + const calculatedFinish = getExpectedFinish(state); + // expected finish is not a duration but a point in time expect(calculatedFinish).toBe(600000); }); }); }); describe('getCurrent()', () => { - it('is null if it hasnt started', () => { - const startedAt = null; - const duration = 0; - const pausedTime = 0; - const addedTime = 0; - const clock = 0; - const endTime = 0; - const timerType = TimerType.CountDown; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); - expect(current).toBe(null); + it('is duration if it hasnt started', () => { + const state = { + eventNow: { + timeEnd: 30, + }, + timer: { + addedTime: 10, + clock: 0, + duration: 111, // <-- we take the duration value + startedAt: null, + finishedAt: null, + pausedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const current = getCurrent(state); + expect(current).toBe(111); }); it('is the remaining time in clock', () => { - const startedAt = 0; - const duration = 10; - const pausedTime = 0; - const addedTime = 0; - const clock = 1; - const endTime = 10; - const timerType = TimerType.CountDown; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 10, + }, + timer: { + addedTime: 0, + clock: 1, + duration: 10, + startedAt: 0, + finishedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(9); }); it('accounts for added times', () => { - const startedAt = 0; - const duration = 10; - const pausedTime = 5; - const addedTime = 5; - const clock = 1; - const endTime = 10; - const timerType = TimerType.CountDown; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 10, + }, + timer: { + addedTime: 10, + clock: 1, + duration: 10, + startedAt: 0, + finishedAt: null, + pausedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(19); }); it('counts over midnight', () => { - const startedAt = 10; - const duration = dayInMs + 10; - const pausedTime = 0; - const addedTime = 0; - const clock = 10; - const endTime = 20; - const timerType = TimerType.CountDown; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 20, + }, + timer: { + addedTime: 0, + clock: 10, + duration: dayInMs + 10, + startedAt: 10, + finishedAt: null, + pausedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(dayInMs + 10); }); it('rolls over midnight', () => { - const startedAt = 10; - const duration = dayInMs + 10; - const pausedTime = 0; - const addedTime = 0; - const clock = 5; - const endTime = 20; - const timerType = TimerType.CountDown; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 20, + }, + timer: { + addedTime: 0, + clock: 5, + duration: dayInMs + 10, + startedAt: 10, + finishedAt: null, + pausedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(15); }); it('midnight holds delays', () => { - const startedAt = 10; - const duration = dayInMs + 10; - const pausedTime = 10; - const addedTime = 10; - const clock = 5; - const endTime = 20; - const timerType = TimerType.CountDown; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 20, + }, + timer: { + addedTime: 20, + clock: 5, + duration: dayInMs + 10, + startedAt: 10, + finishedAt: null, + pausedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(35); }); describe('on timers of type time-to-end', () => { it('current time is the time to end', () => { - const startedAt = 10; - const duration = 100; - const pausedTime = 0; - const addedTime = 0; - const clock = 30; - const endTime = 100; - const timerType = TimerType.TimeToEnd; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 100, + }, + timer: { + addedTime: 0, + clock: 30, + duration: 100, + startedAt: 10, + finishedAt: null, + pausedAt: null, + timerType: TimerType.TimeToEnd, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(70); }); it('current time is the time to end + added time', () => { - const startedAt = 10; - const duration = 100; - const pausedTime = 3; - const addedTime = 4; - const clock = 30; - const endTime = 100; - const timerType = TimerType.TimeToEnd; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 100, + }, + timer: { + addedTime: 7, + clock: 30, + duration: 100, + startedAt: 10, + finishedAt: null, + pausedAt: null, + timerType: TimerType.TimeToEnd, + }, + } as TState; + + const current = getCurrent(state); expect(current).toBe(77); }); it('handles events that finish the day after', () => { - const startedAt = 79200000; // 22:00:00 - const duration = Infinity; // not relevant - const pausedTime = 0; - const addedTime = 0; - const clock = 79500000; // 22:05:00 - const endTime = 600000; // 00:10:00 - const timerType = TimerType.TimeToEnd; - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); - expect(current).toBe(600000 + dayInMs - clock); + const state = { + eventNow: { + timeEnd: 600000, // 00:10:00 + }, + timer: { + addedTime: 0, + clock: 79500000, // 22:05:00 + duration: Infinity, // not relevant, + startedAt: 79200000, // 22:00:00 + finishedAt: null, + pausedAt: null, + timerType: TimerType.TimeToEnd, + }, + } as TState; + + const current = getCurrent(state); + expect(current).toBe(600000 + dayInMs - 79500000); + }); + it('does not update ', () => { + const state = { + eventNow: { + timeEnd: 600000, // 00:10:00 + }, + timer: { + addedTime: 0, + clock: 79500000, // 22:05:00 + duration: Infinity, // not relevant, + startedAt: 79200000, // 22:00:00 + finishedAt: null, + pausedAt: null, + timerType: TimerType.TimeToEnd, + }, + } as TState; + + const current = getCurrent(state); + expect(current).toBe(600000 + dayInMs - 79500000); }); }); }); describe('getExpectedFinish() and getCurrentTime() combined', () => { it('without added times, they combine to be duration', () => { - const startedAt = 0; const duration = 10; - const finishedAt = null; - const pausedTime = 0; - const addedTime = 0; - const clock = 0; - const endTime = 10; - const timerType = TimerType.CountDown; - const expectedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 10, + }, + timer: { + addedTime: 0, + clock: 0, + duration, + startedAt: 0, + pausedAt: null, + finishedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const expectedFinish = getExpectedFinish(state); + const current = getCurrent(state); + const elapsed = duration - current; expect(expectedFinish).toBe(10); expect(elapsed).toBe(0); @@ -330,24 +415,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => { expect(elapsed + current).toBe(10); }); it('added times influence expected finish', () => { - const startedAt = 0; const duration = 10; - const finishedAt = null; - const pausedTime = 1; - const addedTime = 2; - const clock = 5; - const endTime = 10; - const timerType = TimerType.CountDown; - const expectedFinish = getExpectedFinish( - startedAt, - finishedAt, - duration, - pausedTime, - addedTime, - endTime, - timerType, - ); - const current = getCurrent(startedAt, duration, addedTime, pausedTime, clock, endTime, timerType); + const state = { + eventNow: { + timeEnd: 10, + }, + timer: { + addedTime: 3, + clock: 5, + duration, + startedAt: 0, + pausedAt: null, + finishedAt: null, + timerType: TimerType.CountDown, + }, + } as TState; + + const expectedFinish = getExpectedFinish(state); + const current = getCurrent(state); + const elapsed = duration - current; expect(expectedFinish).toBe(13); expect(elapsed).toBe(2); @@ -362,12 +448,19 @@ describe('skippedOutOfEvent()', () => { const duration = 1000; const expectedFinish = startedAt + duration; const previousTime = expectedFinish - testSkipLimit / 2; + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); - clock += testSkipLimit; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + // @ts-expect-error -- cheating in tests + state.timer.clock += testSkipLimit; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); it('allows rolling backwards in an event', () => { @@ -376,11 +469,19 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = startedAt + duration; const previousTime = startedAt + testSkipLimit / 2; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock -= testSkipLimit; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock += testSkipLimit; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); it('accounts for crossing midnight', () => { @@ -388,11 +489,19 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = 10; const previousTime = dayInMs - 1; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock = testSkipLimit - 2; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock = testSkipLimit - 2; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); it('allows rolling backwards in an event across midnight', () => { @@ -400,11 +509,19 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = 10; const previousTime = startedAt + 1; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock -= testSkipLimit; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock -= testSkipLimit; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); }); it('finds skip forwards out of event', () => { @@ -413,11 +530,19 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = startedAt + duration; const previousTime = expectedFinish - testSkipLimit / 2; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock += testSkipLimit + 1; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock += testSkipLimit + 1; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); it('finds skip backwards out of event', () => { @@ -426,11 +551,19 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = startedAt + duration; const previousTime = startedAt + testSkipLimit / 2; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock -= testSkipLimit + 1; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock -= testSkipLimit + 1; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); it('finds skip forwards out of event across midnight', () => { @@ -438,11 +571,19 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = 10; const previousTime = dayInMs - 3; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock = testSkipLimit - 2; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock = testSkipLimit - 2; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); }); it('finds skip backwards out of event across midnight', () => { @@ -450,10 +591,715 @@ describe('skippedOutOfEvent()', () => { const expectedFinish = 10; const previousTime = startedAt + 1; - let clock = previousTime; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false); + const state = { + timer: { + clock: previousTime, + expectedFinish, + startedAt, + }, + } as TState; - clock -= testSkipLimit + 1; - expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true); + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false); + + // @ts-expect-error -- cheating in tests + state.timer.clock -= testSkipLimit + 1; + expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true); + }); +}); + +describe('getRollTimers()', () => { + const eventlist: Partial[] = [ + { + id: '1', + timeStart: 5, + timeEnd: 10, + isPublic: false, + }, + { + id: '2', + timeStart: 10, + timeEnd: 20, + isPublic: false, + }, + { + id: '3', + timeStart: 20, + timeEnd: 30, + isPublic: false, + }, + { + id: '4', + timeStart: 30, + timeEnd: 40, + isPublic: false, + }, + { + id: '5', + timeStart: 40, + timeEnd: 50, + isPublic: true, + }, + { + id: '6', + timeStart: 50, + timeEnd: 60, + isPublic: false, + }, + { + id: '7', + timeStart: 60, + timeEnd: 70, + isPublic: true, + }, + { + id: '8', + timeStart: 70, + timeEnd: 80, + isPublic: false, + }, + ]; + + it('if timer is at 0', () => { + const now = 0; + const expected = { + nowIndex: null, + nowId: null, + publicIndex: null, + nextIndex: 0, + publicNextIndex: 4, + timeToNext: 5, + nextEvent: eventlist[0], + nextPublicEvent: eventlist[4], + currentEvent: null, + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 5', () => { + const now = 5; + const expected = { + nowIndex: 0, + nowId: eventlist[0].id, + publicIndex: null, + nextIndex: 1, + publicNextIndex: 4, + timeToNext: 5, + nextEvent: eventlist[1], + nextPublicEvent: eventlist[4], + currentEvent: eventlist[0], + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 15', () => { + const now = 15; + const expected = { + nowIndex: 1, + nowId: eventlist[1].id, + publicIndex: null, + nextIndex: 2, + publicNextIndex: 4, + timeToNext: 5, + nextEvent: eventlist[2], + nextPublicEvent: eventlist[4], + currentEvent: eventlist[1], + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 20', () => { + const now = 20; + const expected = { + nowIndex: 2, + nowId: eventlist[2].id, + publicIndex: null, + nextIndex: 3, + publicNextIndex: 4, + timeToNext: 10, + nextEvent: eventlist[3], + nextPublicEvent: eventlist[4], + currentEvent: eventlist[2], + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 49', () => { + const now = 49; + const expected = { + nowIndex: 4, + nowId: eventlist[4].id, + publicIndex: 4, + nextIndex: 5, + publicNextIndex: 6, + timeToNext: 1, + nextEvent: eventlist[5], + nextPublicEvent: eventlist[6], + currentEvent: eventlist[4], + currentPublicEvent: eventlist[4], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 63', () => { + const now = 63; + const expected = { + nowIndex: 6, + nowId: eventlist[6].id, + publicIndex: 6, + nextIndex: 7, + publicNextIndex: null, + timeToNext: 7, + nextEvent: eventlist[7], + nextPublicEvent: null, + currentEvent: eventlist[6], + currentPublicEvent: eventlist[6], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 75', () => { + const now = 75; + const expected = { + nowIndex: 7, + nowId: eventlist[7].id, + publicIndex: 6, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: eventlist[7], + currentPublicEvent: eventlist[6], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 100 we roll to day after', () => { + const now = 100; + const expected = { + nowIndex: null, + nowId: null, + publicIndex: null, + nextIndex: 0, + publicNextIndex: 4, + timeToNext: dayInMs - now + eventlist[0].timeStart, + nextEvent: eventlist[0], + nextPublicEvent: eventlist[4], + currentEvent: null, + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('handles rolls to next day with real values', () => { + const singleEventList: Partial[] = [ + { + id: '1', + timeStart: 36000000, // 10:00 + timeEnd: 39600000, // 11:00 + isPublic: true, + }, + ]; + const now = 64800000; // 18:00 + const expected = { + nowIndex: null, + nowId: null, + publicIndex: null, + nextIndex: 0, + publicNextIndex: 0, + timeToNext: dayInMs - now + singleEventList[0].timeStart, + nextEvent: singleEventList[0], + nextPublicEvent: singleEventList[0], + currentEvent: null, + currentPublicEvent: null, + }; + const state = getRollTimers(singleEventList as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('handles rolls to next day with real values', () => { + const singleEventList: Partial[] = [ + { + id: '1', + timeStart: 36000000, // 10:00 + timeEnd: 3600000, // 01:00 + isPublic: true, + }, + ]; + const now = 60000; // 00:01 + const expected = { + nowIndex: 0, + nowId: singleEventList[0].id, + publicIndex: 0, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: singleEventList[0], + currentPublicEvent: singleEventList[0], + }; + const state = getRollTimers(singleEventList as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + it('handles rolls to next day with real values', () => { + const singleEventList: Partial[] = [ + { + id: '1', + timeStart: 36000000, // 10:00 + timeEnd: 3600000, // 01:00 + isPublic: true, + }, + ]; + const now = 60000; // 00:01 + const expected = { + nowIndex: 0, + nowId: singleEventList[0].id, + publicIndex: 0, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: singleEventList[0], + currentPublicEvent: singleEventList[0], + }; + const state = getRollTimers(singleEventList as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('handles roll that goes over midnight', () => { + const singleEventList: Partial[] = [ + { + id: '1', + timeStart: 72000000, // 20:00 + timeEnd: 60000, // 00:10 + isPublic: true, + }, + ]; + const now = 6000; // 00:01 + const expected = { + nowIndex: 0, + nowId: singleEventList[0].id, + publicIndex: 0, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: singleEventList[0], + currentPublicEvent: singleEventList[0], + }; + const state = getRollTimers(singleEventList as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); +}); + +describe('getRollTimers() test that roll behaviour with overlapping times', () => { + const eventlist: Partial[] = [ + { + id: '1', + timeStart: 10, + timeEnd: 10, + isPublic: false, + }, + { + id: '2', + timeStart: 10, + timeEnd: 20, + isPublic: true, + }, + { + id: '3', + timeStart: 10, + timeEnd: 30, + isPublic: false, + }, + ]; + + it('if timer is at 0', () => { + const now = 0; + const expected = { + nowIndex: null, + nowId: null, + publicIndex: null, + nextIndex: 0, + publicNextIndex: 1, + timeToNext: 10, + nextEvent: eventlist[0], + nextPublicEvent: eventlist[1], + currentEvent: null, + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 10', () => { + const now = 10; + const expected = { + nowIndex: 1, + nowId: eventlist[1].id, + publicIndex: 1, + nextIndex: 2, + publicNextIndex: null, + timeToNext: 0, + nextEvent: eventlist[2], + nextPublicEvent: null, + currentEvent: eventlist[1], + currentPublicEvent: eventlist[1], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 15', () => { + const now = 15; + const expected = { + nowIndex: 1, + nowId: eventlist[1].id, + publicIndex: 1, + nextIndex: 2, + publicNextIndex: null, + timeToNext: -5, + nextEvent: eventlist[2], + nextPublicEvent: null, + currentEvent: eventlist[1], + currentPublicEvent: eventlist[1], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 20', () => { + const now = 20; + const expected = { + nowIndex: 2, + nowId: eventlist[2].id, + publicIndex: 1, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: eventlist[2], + currentPublicEvent: eventlist[1], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if timer is at 25', () => { + const now = 25; + const expected = { + nowIndex: 2, + nowId: eventlist[2].id, + publicIndex: 1, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: eventlist[2], + currentPublicEvent: eventlist[1], + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); +}); + +// issue #58 +describe('getRollTimers() test that roll behaviour multi day event edge cases', () => { + it('if the start time is the day after end time, and start time is earlier than now', () => { + const now = 66600000; // 19:30 + const eventlist: Partial[] = [ + { + id: '1', + timeStart: 66000000, // 19:20 + timeEnd: 54600000, // 16:10 + isPublic: false, + }, + ]; + const expected = { + nowIndex: 0, + nowId: '1', + publicIndex: null, + nextIndex: null, + publicNextIndex: null, + timeToNext: null, + nextEvent: null, + nextPublicEvent: null, + currentEvent: eventlist[0], + currentPublicEvent: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); + + it('if the start time is the day after end time, and both are later than now', () => { + const now = 66840000; // 19:34 + const eventlist: Partial[] = [ + { + id: '1', + timeStart: 67200000, // 19:40 + timeEnd: 66900000, // 19:35 + isPublic: false, + }, + ]; + const expected = { + currentEvent: { + id: '1', + isPublic: false, + timeEnd: 66900000, + timeStart: 67200000, + }, + currentPublicEvent: null, + nextEvent: null, + nextIndex: null, + nextPublicEvent: null, + nowId: '1', + nowIndex: 0, + publicIndex: null, + publicNextIndex: null, + timeToNext: null, + }; + + const state = getRollTimers(eventlist as OntimeEvent[], now); + expect(state).toStrictEqual(expected); + }); +}); + +test('normaliseEndTime()', () => { + const t1 = { + start: 10, + end: 20, + }; + const t1_expected = 20; + + expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected); + + const t2 = { + start: 10 + dayInMs, + end: 20, + }; + const t2_expected = 20 + dayInMs; + + expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected); + + const t3 = { + start: 10, + end: 10, + }; + const t3_expected = 10; + + expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected); +}); + +describe('updateRoll()', () => { + it('it updates running events correctly', () => { + const timers = { + runtime: { + selectedEventId: '1', + }, + timer: { + current: 10, + expectedFinish: 15, + clock: 11, + secondaryTimer: null, + secondaryTarget: null, + }, + } as TState; + + const expected = { + updatedTimer: 15 - 11, + updatedSecondaryTimer: null, // usually clock - expectedFinish + doRollLoad: false, + isFinished: false, + }; + + 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.timer.clock = 600; + expected.updatedTimer = 1000 - 600; + + expect(updateRoll(timers)).toStrictEqual(expected); + }); + + it('it updates secondary timer', () => { + const timers = { + runtime: { + selectedEventId: null, + }, + timer: { + current: null, + expectedFinish: null, + clock: 11, + secondaryTimer: 1, + secondaryTarget: 15, + }, + } as TState; + + 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 = { + runtime: { + selectedEventId: '1', + }, + timer: { + startedAt: 0, + current: 10, + expectedFinish: 11, + clock: 12, + secondaryTimer: null, + secondaryTarget: null, + }, + } as TState; + + 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 = { + runtime: { + selectedEventId: null, + }, + timer: { + startedAt: null, + current: null, + expectedFinish: null, + clock: 16, + secondaryTimer: 1, + secondaryTarget: 15, + }, + } as TState; + + 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 = { + runtime: { + selectedEventId: null, + }, + timer: { + current: null, + expectedFinish: null, + clock: 15, + secondaryTimer: 0, + secondaryTarget: 15, + }, + } as TState; + + const expected = { + updatedTimer: null, + updatedSecondaryTimer: 0, + doRollLoad: true, + isFinished: false, + }; + + expect(updateRoll(timers)).toStrictEqual(expected); + }); + + it('counts over midnight', () => { + const timers = { + runtime: { + selectedEventId: '1', + }, + timer: { + current: 25, + expectedFinish: 10, + startedAt: 1000, + clock: dayInMs - 10, + secondaryTimer: null, + secondaryTarget: null, + }, + } as TState; + + const expected = { + updatedTimer: 20, + updatedSecondaryTimer: null, + doRollLoad: false, + isFinished: false, + }; + + expect(updateRoll(timers)).toStrictEqual(expected); + }); + + it('rolls over midnight', () => { + const timers = { + runtime: { + selectedEventId: '1', + }, + timer: { + current: dayInMs, + expectedFinish: 10, + startedAt: 1000, + clock: 10, + secondaryTimer: null, + secondaryTarget: null, + }, + } as TState; + + const expected = { + updatedTimer: dayInMs, + updatedSecondaryTimer: null, + doRollLoad: false, + isFinished: false, + }; + + expect(updateRoll(timers)).toStrictEqual(expected); }); }); diff --git a/apps/server/src/services/rollUtils.ts b/apps/server/src/services/rollUtils.ts deleted file mode 100644 index 98a6feac2..000000000 --- a/apps/server/src/services/rollUtils.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { OntimeEvent } from 'ontime-types'; -import { dayInMs } from 'ontime-utils'; - -/** - * handle events that span over midnight - */ -export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end); - -/** - * @description Sorts an array of objects by given property - * @param {array} arr - array to be sorted - * @param {string} property - property to compare - * @returns {array} copy of array sorted in ascending order - */ - -export const sortArrayByProperty = (arr: T[], property: string): T[] => { - return [...arr].sort((a, b) => { - return a[property] - b[property]; - }); -}; - -/** - * Finds loading information given a current rundown and time - * @param {OntimeEvent[]} rundown - List of playable events - * @param {number} timeNow - time now in ms - * @returns {{}} - */ -export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => { - let nowIndex: number | null = null; // index of event now - let nowId: string | null = null; // id of event now - let publicIndex: number | null = null; // index of public event now - let nextIndex: number | null = null; // index of next event - let publicNextIndex: number | null = null; // index of next public event - let timeToNext: number | null = null; // counter: time for next event - let publicTimeToNext: number | null = null; // counter: time for next public event - - const orderedEvents = sortArrayByProperty(rundown, 'timeStart'); - const lastEvent = orderedEvents[orderedEvents.length - 1]; - const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd); - - let nextEvent: OntimeEvent | null = null; - let nextPublicEvent: OntimeEvent | null = null; - let currentEvent: OntimeEvent | null = null; - let currentPublicEvent: OntimeEvent | null = null; - - if (timeNow > lastNormalEnd) { - // we are past last end - // preload first and find next - - const firstEvent = orderedEvents[0]; - nextIndex = 0; - nextEvent = firstEvent; - timeToNext = firstEvent.timeStart + dayInMs - timeNow; - - if (firstEvent.isPublic) { - nextPublicEvent = firstEvent; - publicNextIndex = 0; - } else { - // look for next public - // dev note: we feel that this is more efficient than filtering - // since the next event will likely be close to the one playing - for (const event of orderedEvents) { - if (event.isPublic) { - nextPublicEvent = event; - // we need the index before this was sorted - publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - break; - } - } - } - } else { - // flags: select first event if several overlapping - let nowFound = false; - // keep track of the end times when looking for public - let publicTime = -1; - - for (const event of orderedEvents) { - // When does the event end (handle midnight) - const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd); - - const hasNotEnded = normalEnd > timeNow; - const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd; - const hasStarted = isFromDayBefore || timeNow >= event.timeStart; - - if (normalEnd <= timeNow) { - // event ran already - - if (event.isPublic && normalEnd > publicTime) { - // public event might not be the one running - publicTime = normalEnd; - currentPublicEvent = event; - publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - } else if (hasNotEnded && hasStarted && !nowFound) { - // event is running - currentEvent = event; - nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - nowId = event.id; - nowFound = true; - - // it could also be public - if (event.isPublic) { - publicTime = normalEnd; - currentPublicEvent = event; - publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - } else if (normalEnd > timeNow) { - // event will run - - // we already know whats next and next-public - if (nextIndex !== null && publicNextIndex !== null) { - continue; - } - - // look for next events - // check how far the start is from now - const timeToEventStart = event.timeStart - timeNow; - - // we don't have a next or this one starts sooner than current next - if (nextIndex === null || timeToEventStart < timeToNext) { - timeToNext = timeToEventStart; - nextEvent = event; - nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - - if (event.isPublic) { - // if we don't have a public next or this one start sooner than assigned next - if (publicNextIndex === null || timeToEventStart < publicTimeToNext) { - publicTimeToNext = timeToEventStart; - nextPublicEvent = event; - publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); - } - } - } - } - } - - return { - nowIndex, - nowId, - publicIndex, - nextIndex, - publicNextIndex, - timeToNext, - nextEvent, - nextPublicEvent, - currentEvent, - currentPublicEvent, - }; -}; - -type CurrentTimers = { - selectedEventId: string | null; - current: number | null; - _finishAt: number | null; - clock: number | null; - secondaryTimer: number | null; - secondaryTarget: number | null; -}; - -/** - * @description Implements update functions for roll mode - * @param {CurrentTimers} currentTimers - * @returns {object} object with selection variables - */ -export const updateRoll = (currentTimers: CurrentTimers) => { - const { selectedEventId, current, _finishAt, clock, secondaryTimer, secondaryTarget } = currentTimers; - - // 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 - - updatedTimer = _finishAt - clock; - if (updatedTimer > dayInMs) { - updatedTimer -= dayInMs; - } - - if (updatedTimer < 0) { - 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 }; -}; diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 9c45c3740..f8c3e002f 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -1,18 +1,9 @@ -import { - LogOrigin, - OntimeBaseEvent, - OntimeBlock, - OntimeDelay, - OntimeEvent, - Playback, - SupportedEvent, -} from 'ontime-types'; +import { LogOrigin, OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from 'ontime-types'; import { generateId, getCueCandidate } from 'ontime-utils'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; import { MAX_EVENTS } from '../../settings.js'; -import { EventLoader, eventLoader } from '../../classes/event-loader/EventLoader.js'; -import { eventTimer } from '../TimerService.js'; +import { EventLoader } from '../../classes/event-loader/EventLoader.js'; import { sendRefetch } from '../../adapters/websocketAux.js'; import { runtimeCacheStore } from '../../stores/cachingStore.js'; import { @@ -28,130 +19,17 @@ import { } from './delayedRundown.utils.js'; import { logger } from '../../classes/Logger.js'; import { validateEvent } from '../../utils/parser.js'; -import { clock } from '../Clock.js'; -import { state } from '../../state.js'; +import { stateMutations } from '../../state.js'; +import { runtimeService } from '../runtime-service/RuntimeService.js'; /** * Forces rundown to be recalculated * To be used when we know the rundown has changed completely */ export function forceReset() { - eventLoader.reset(); + runtimeService.reset(); runtimeCacheStore.invalidate(delayedRundownCacheKey); } - -/** - * Checks if a list of IDs is in the current selection - */ -const affectedLoaded = (affectedIds: string[]) => { - const now = eventLoader.loaded.selectedEventId; - const nowPublic = eventLoader.loaded.selectedPublicEventId; - const next = eventLoader.loaded.nextEventId; - const nextPublic = eventLoader.loaded.nextPublicEventId; - return ( - affectedIds.includes(now) || - affectedIds.includes(nowPublic) || - affectedIds.includes(next) || - affectedIds.includes(nextPublic) - ); -}; - -/** - * Checks if timer replaces the loaded next - */ -const isNewNext = () => { - const timedEvents = EventLoader.getTimedEvents(); - const now = eventLoader.loaded.selectedEventId; - const next = eventLoader.loaded.nextEventId; - - // 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 = eventLoader.loaded.selectedPublicEventId; - const nextPublic = eventLoader.loaded.nextPublicEventId; - - 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; -}; - -/** - * Updates timer service when a relevant piece of data changes - */ -export function updateTimer(affectedIds?: string[]) { - const runningEventId = eventLoader.loaded.selectedEventId; - const nextEventId = eventLoader.loaded.nextEventId; - - if (runningEventId === null && nextEventId === null) { - return false; - } - - // we need to reload in a few scenarios: - // 1. we are not confident that changes do not affect running event - const safeOption = typeof affectedIds === 'undefined'; - // 2. the edited event is in memory (now or next) running - const eventInMemory = safeOption ? false : affectedLoaded(affectedIds); - // 3. the edited event replaces next event - const isNext = isNewNext(); - - if (safeOption) { - eventLoader.reset(); - const { eventNow } = eventLoader.loadById(runningEventId) || {}; - eventTimer.hotReload(eventNow); - return true; - } - - if (eventInMemory) { - eventLoader.reset(); - - if (state.playback === Playback.Roll) { - const rollTimers = eventLoader.findRoll(clock.timeNow()); - if (rollTimers === null) { - eventTimer.stop(); - } else { - const { currentEvent, nextEvent } = rollTimers; - eventTimer.roll(currentEvent, nextEvent); - } - } else { - const { eventNow } = eventLoader.loadById(runningEventId) || {}; - if (eventNow) { - eventTimer.hotReload(eventNow); - } else { - eventTimer.stop(); - } - } - return true; - } - - if (isNext) { - const { eventNow } = eventLoader.loadById(runningEventId) || {}; - eventTimer.hotReload(eventNow); - return true; - } - return false; -} - /** * @description creates a new event with given data * @param {object} eventData @@ -216,8 +94,8 @@ export async function editEvent(eventData: Partial | Partial) { await cachedBatchEdit(ids, data); - // notify timer service of changed events - updateTimer(ids); + // notify runtime service of changed events + runtimeService.update(ids); // advice socket subscribers of change sendRefetch(); @@ -243,7 +121,8 @@ export async function deleteEvent(eventId) { export async function deleteAllEvents() { await cachedClear(); - notifyChanges({ timer: true, external: true, reset: true }); + // no need to modify timer since we will reset + notifyChanges({ external: true, reset: true }); } /** @@ -284,7 +163,8 @@ export async function swapEvents(from: string, to: string) { * Called when we make changes to the rundown object */ function updateChangeNumEvents() { - eventLoader.updateNumEvents(); + const numEvents = EventLoader.getPlayableEvents().length; + stateMutations.updateNumEvents(numEvents); } /** @@ -293,10 +173,11 @@ function updateChangeNumEvents() { export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) { if (options.timer) { // notify timer service of changed events + // timer can be true or an array of changed IDs if (Array.isArray(options.timer)) { - updateTimer(options.timer); + runtimeService.update(options.timer); } - updateTimer(); + runtimeService.update(); } if (options.reset) { diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts new file mode 100644 index 000000000..1dedb4cb4 --- /dev/null +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -0,0 +1,352 @@ +import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; +import { millisToString, validatePlayback } from 'ontime-utils'; + +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'; + +/** + * Service manages runtime status of app + * Coordinating with necessary services + */ +class RuntimeService { + private eventTimer: TimerService; + + constructor() {} + + 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 }); + + if (resumable) { + this.resume(resumable); + } + } + + shutdown() { + 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 now = state.runtime.selectedEventId; + const nowPublic = state.runtime.selectedPublicEventId; + const next = state.runtime.nextEventId; + const nextPublic = state.runtime.nextPublicEventId; + return ( + affectedIds.includes(now) || + affectedIds.includes(nowPublic) || + affectedIds.includes(next) || + affectedIds.includes(nextPublic) + ); + } + + private isNewNext() { + const timedEvents = EventLoader.getPlayableEvents(); + const now = state.runtime.selectedEventId; + const next = state.runtime.nextEventId; + + // 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.runtime.selectedPublicEventId; + const nextPublic = state.runtime.nextPublicEventId; + + 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; + } + + reset() { + stateMutations.timer.clear(); + } + + /** + * check whether underlying data of runtime has changed + */ + update(affectedIds?: string[]) { + const hasLoadedElements = state.runtime.selectedEventId && state.runtime.nextEventId; + if (!hasLoadedElements) { + return; + } + + // we need to reload in a few scenarios: + // 1. we are not confident that changes do not affect running event + const safeOption = typeof affectedIds === 'undefined'; + // 2. the edited event is in memory (now or next) running + const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds); + // 3. the edited event replaces next event + let isNext = false; + + if (safeOption || eventInMemory) { + if (state.playback === Playback.Roll) { + this.roll(); + } + // load stuff again, but keep running if our events still exist + const eventNow = EventLoader.getEventWithId(state.runtime.selectedEventId); + if (eventNow) { + stateMutations.reload(eventNow); + } + return; + } + + isNext = this.isNewNext(); + if (isNext) { + // TODO: do i need to load here? + const playableEvents = EventLoader.getPlayableEvents(); + stateMutations.loadNext(playableEvents); + } + } + + /** + * makes calls for loading and starting given event + * @param {OntimeEvent} event + * @return {boolean} success - whether an event was loaded + */ + loadEvent(event: OntimeEvent): boolean { + if (event.skip) { + logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); + return false; + } + + const timedEvents = EventLoader.getPlayableEvents(); + stateMutations.load(event, timedEvents); + const success = event.id === state.runtime.selectedEventId; + + if (success) { + logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); + } + return success; + } + + /** + * starts event matching given ID + * @param {string} eventId + * @return {boolean} success - whether an event was loaded + */ + startById(eventId: string): boolean { + const event = EventLoader.getEventWithId(eventId); + const success = this.loadEvent(event); + if (success) { + this.start(); + } + return success; + } + + /** + * starts an event at index + * @param {number} eventIndex + * @return {boolean} success - whether an event was loaded + */ + startByIndex(eventIndex: number): boolean { + const event = EventLoader.getEventAtIndex(eventIndex); + const success = this.loadEvent(event); + if (success) { + this.start(); + } + return success; + } + + /** + * starts first event matching given cue + * @param {string} cue + * @return {boolean} success - whether an event was loaded + */ + startByCue(cue: string): boolean { + const event = EventLoader.getEventWithCue(cue); + const success = this.loadEvent(event); + if (success) { + this.start(); + } + return success; + } + + /** + * loads event matching given ID + * @param {string} eventId + * @return {boolean} success - whether an event was loaded + */ + loadById(eventId: string): boolean { + const event = EventLoader.getEventWithId(eventId); + const success = this.loadEvent(event); + return success; + } + + /** + * loads event matching given ID + * @param {number} eventIndex + * @return {boolean} success - whether an event was loaded + */ + loadByIndex(eventIndex: number): boolean { + const event = EventLoader.getEventAtIndex(eventIndex); + const success = this.loadEvent(event); + return success; + } + + /** + * loads first event matching given cue + * @param {string} cue + * @return {boolean} success - whether an event was loaded + */ + loadByCue(cue: string): boolean { + const event = EventLoader.getEventWithCue(cue); + const success = this.loadEvent(event); + return success; + } + + /** + * Loads event before currently selected + * @return {boolean} success - whether an event was loaded + */ + loadPrevious(): boolean { + const previousEvent = EventLoader.findPrevious(state.runtime.selectedEventId); + if (previousEvent) { + const success = this.loadEvent(previousEvent); + return success; + } + return false; + } + + /** + * Loads event after currently selected + * @return {boolean} success + */ + loadNext(): boolean { + const nextEvent = EventLoader.findNext(state.runtime.selectedEventId); + if (nextEvent) { + const success = this.loadEvent(nextEvent); + return success; + } + + logger.info(LogOrigin.Playback, 'No next event found! Continuing playback'); + return false; + } + + /** + * Starts playback on selected event + */ + start() { + const canStart = validatePlayback(state.playback).start; + if (canStart) { + this.eventTimer.start(); + logger.info(LogOrigin.Playback, `Play Mode ${state.playback.toUpperCase()}`); + } + } + + /** + * Starts playback on next event + */ + startNext() { + const hasNext = this.loadNext(); + if (hasNext) { + this.start(); + } + } + + /** + * Pauses playback on selected event + */ + pause() { + if (validatePlayback(state.playback).pause) { + this.eventTimer.pause(); + const newState = state.playback; + logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); + } + } + + /** + * Stops timer and unloads any events + */ + stop() { + if (validatePlayback(state.playback).stop) { + this.eventTimer.stop(); + const newState = state.playback; + logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); + } + } + + /** + * Reloads current event + */ + reload() { + if (state.runtime.selectedEventId) { + stateMutations.reload(); + } + } + + /** + * Sets playback to roll + */ + roll() { + const playableEvents = EventLoader.getPlayableEvents(); + try { + this.eventTimer.roll(playableEvents); + } catch (error) { + logger.warning(LogOrigin.Server, `Roll: ${error}`); + } + + const newState = state.playback; + logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); + } + + /** + * @description resume playback state given a restore point + * @param restorePoint + */ + resume(restorePoint: RestorePoint) { + const { selectedEventId, playback } = restorePoint; + if (playback === Playback.Roll) { + this.roll(); + } + + // the db would have to change for the event not to exist + // we do not kow the reason for the crash, so we check anyway + const event = EventLoader.getEventWithId(selectedEventId); + if (!event) { + return; + } + + const timedEvents = EventLoader.getPlayableEvents(); + stateMutations.resume(restorePoint, event, timedEvents); + logger.info(LogOrigin.Playback, 'Resuming playback'); + } + + /** + * Adds time to current event + * @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)}`); + } +} + +export const runtimeService = new RuntimeService(); diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 5344eda8c..66bc1e29b 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,20 +1,22 @@ -import { MaybeNumber, TimerType } from 'ontime-types'; +import { MaybeNumber, OntimeEvent, TimerType } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; +import { TState } from '../state.js'; +import { sortArrayByProperty } from '../utils/arrayUtils.js'; -// TODO: timerUtils receive entire state object +/** + * handle events that span over midnight + */ +export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end); /** * Calculates expected finish time of a running timer + * @param {TState} state runtime state + * @returns {number | null} new current time or null if nothing is running */ -export function getExpectedFinish( - startedAt: MaybeNumber, - finishedAt: MaybeNumber, - duration: number, - pausedTime: number, - addedTime: number, - timeEnd: number, - timerType: TimerType, -) { +export function getExpectedFinish(state: TState): MaybeNumber { + const { startedAt, clock, finishedAt, duration, addedTime, timerType, pausedAt } = state.timer; + const { timeEnd } = state.eventNow; + if (startedAt === null) { return null; } @@ -23,12 +25,14 @@ export function getExpectedFinish( return finishedAt; } + const pausedTime = pausedAt != null ? clock - pausedAt : 0; + if (timerType === TimerType.TimeToEnd) { return timeEnd + addedTime + pausedTime; } // handle events that finish the day after - const expectedFinish = startedAt + duration + pausedTime + addedTime; + const expectedFinish = startedAt + duration + addedTime + pausedTime; if (expectedFinish > dayInMs) { return expectedFinish - dayInMs; } @@ -39,41 +43,41 @@ export function getExpectedFinish( /** * Calculates running countdown + * @param {TState} state runtime state + * @returns {number} current time for timer */ -export function getCurrent( - startedAt: MaybeNumber, - duration: number, - addedTime: number, - pausedTime: number, - clock: number, - timeEnd: number, - timerType: TimerType, -) { - if (startedAt === null) { - return null; - } +export function getCurrent(state: TState): number { + const { startedAt, duration, addedTime, clock, timerType, pausedAt } = state.timer; + const { timeEnd } = state.eventNow; if (timerType === TimerType.TimeToEnd) { - if (startedAt > timeEnd) { - return timeEnd + addedTime + pausedTime + dayInMs - clock; - } - return timeEnd + addedTime + pausedTime - clock; + const isNextDay = startedAt > timeEnd; + const correctDay = isNextDay ? dayInMs : 0; + return timeEnd + addedTime + correctDay - clock; } - if (startedAt > clock) { - // we are the day after the event was started - return startedAt + duration + addedTime + pausedTime - clock - dayInMs; + if (startedAt === null) { + return duration; } - return startedAt + duration + addedTime + pausedTime - clock; + + const hasPassedMidnight = startedAt > clock; + const correctDay = hasPassedMidnight ? dayInMs : 0; + if (pausedAt != null) { + return startedAt + duration + addedTime - pausedAt; + } + + return startedAt + duration + addedTime - clock - correctDay; } -export function skippedOutOfEvent( - previousTime: number, - clock: number, - startedAt: number, - expectedFinish: number, - skipLimit: number, -): boolean { +/** + * Checks whether we have skipped out of the event + * @param {TState} 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 { + const { clock, startedAt, expectedFinish } = state.timer; const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit; const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock; @@ -83,3 +87,179 @@ export function skippedOutOfEvent( return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt); } + +/** + * Finds loading information given a current rundown and time + * @param {OntimeEvent[]} rundown - List of playable events + * @param {number} timeNow - time now in ms + * @returns {{}} + */ +export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => { + let nowIndex: number | null = null; // index of event now + let nowId: string | null = null; // id of event now + let publicIndex: number | null = null; // index of public event now + let nextIndex: number | null = null; // index of next event + let publicNextIndex: number | null = null; // index of next public event + let timeToNext: number | null = null; // counter: time for next event + let publicTimeToNext: number | null = null; // counter: time for next public event + + const orderedEvents = sortArrayByProperty(rundown, 'timeStart'); + const lastEvent = orderedEvents[orderedEvents.length - 1]; + const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd); + + let nextEvent: OntimeEvent | null = null; + let nextPublicEvent: OntimeEvent | null = null; + let currentEvent: OntimeEvent | null = null; + let currentPublicEvent: OntimeEvent | null = null; + + if (timeNow > lastNormalEnd) { + // we are past last end + // preload first and find next + + const firstEvent = orderedEvents[0]; + nextIndex = 0; + nextEvent = firstEvent; + timeToNext = firstEvent.timeStart + dayInMs - timeNow; + + if (firstEvent.isPublic) { + nextPublicEvent = firstEvent; + publicNextIndex = 0; + } else { + // look for next public + // dev note: we feel that this is more efficient than filtering + // since the next event will likely be close to the one playing + for (const event of orderedEvents) { + if (event.isPublic) { + nextPublicEvent = event; + // we need the index before this was sorted + publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + break; + } + } + } + } else { + // flags: select first event if several overlapping + let nowFound = false; + // keep track of the end times when looking for public + let publicTime = -1; + + for (const event of orderedEvents) { + // When does the event end (handle midnight) + const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd); + + const hasNotEnded = normalEnd > timeNow; + const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd; + const hasStarted = isFromDayBefore || timeNow >= event.timeStart; + + if (normalEnd <= timeNow) { + // event ran already + + if (event.isPublic && normalEnd > publicTime) { + // public event might not be the one running + publicTime = normalEnd; + currentPublicEvent = event; + publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + } + } else if (hasNotEnded && hasStarted && !nowFound) { + // event is running + currentEvent = event; + nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + nowId = event.id; + nowFound = true; + + // it could also be public + if (event.isPublic) { + publicTime = normalEnd; + currentPublicEvent = event; + publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + } + } else if (normalEnd > timeNow) { + // event will run + + // we already know whats next and next-public + if (nextIndex !== null && publicNextIndex !== null) { + continue; + } + + // look for next events + // check how far the start is from now + const timeToEventStart = event.timeStart - timeNow; + + // we don't have a next or this one starts sooner than current next + if (nextIndex === null || timeToEventStart < timeToNext) { + timeToNext = timeToEventStart; + nextEvent = event; + nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + } + + if (event.isPublic) { + // if we don't have a public next or this one start sooner than assigned next + if (publicNextIndex === null || timeToEventStart < publicTimeToNext) { + publicTimeToNext = timeToEventStart; + nextPublicEvent = event; + publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + } + } + } + } + } + + return { + nowIndex, + nowId, + publicIndex, + nextIndex, + publicNextIndex, + timeToNext, + nextEvent, + nextPublicEvent, + currentEvent, + currentPublicEvent, + }; +}; + +/** + * @description Implements update functions for roll mode + * @param {TState} + * @returns object with selection variables + */ +export const updateRoll = (state: TState) => { + const { selectedEventId } = state.runtime; + const { current, expectedFinish, startedAt, clock, secondaryTimer, secondaryTarget } = state.timer; + + // 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 < 0) { + 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 }; +}; diff --git a/apps/server/src/state.ts b/apps/server/src/state.ts index 0d71f3a2f..439ba03d5 100644 --- a/apps/server/src/state.ts +++ b/apps/server/src/state.ts @@ -1,54 +1,81 @@ import type { DeepReadonly, DeepWritable } from 'ts-essentials'; -import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types'; +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, skippedOutOfEvent } from './services/timerUtils.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 { PlaybackService } from './services/PlaybackService.js'; -import { updateRoll } from './services/rollUtils.js'; +import { runtimeService } from './services/runtime-service/RuntimeService.js'; +import { EventLoader } from './classes/event-loader/EventLoader.js'; // TODO: move to timer config -const timeSkipLimit = 3 * 32; +const timeSkipLimit = 1000; -type TState = DeepReadonly<{ - playback: Playback; +const initialRuntime: Runtime = { + selectedEventIndex: null, + selectedEventId: null, // TODO: remove + selectedPublicEventId: null, // TODO: remove + nextEventId: null, // TODO: remove + nextPublicEventId: null, // TODO: remove + numEvents: 0, +}; + +const initialTimer: TimerState = { + clock: clock.timeNow(), + current: null, + elapsed: null, + expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients + addedTime: 0, + startedAt: null, + finishedAt: null, + secondaryTimer: null, + duration: null, + timerType: null, // TODO: remove + endAction: null, // TODO: remove + timeWarning: null, // TODO: remove + timeDanger: null, // TODO: remove +}; + +export type TState = DeepReadonly<{ + eventNow: OntimeEvent | null; + publicEventNow: OntimeEvent | null; + eventNext: OntimeEvent | null; + publicEventNext: OntimeEvent | null; + runtime: Runtime; + playback: Playback; // TODO: merge into timer? + // TODO: these are private state and should not be emitted timer: TimerState & { - pausedTime: number; - pausedAt: number | null; - timeEnd: number | null; + pausedAt: MaybeNumber; finishedNow: boolean; - lastUpdate: number | null; - secondaryTarget: number | null; + lastUpdate: MaybeNumber; + secondaryTarget: MaybeNumber; }; }>; export const state: TState = { - // QUESTION: should merge playback into the timer? - playback: Playback.Stop, + eventNow: null, + publicEventNow: null, + eventNext: null, + publicEventNext: null, + runtime: initialRuntime, + playback: Playback.Stop, // TODO: merge into timer? timer: { - clock: clock.timeNow(), - current: null, - elapsed: null, - expectedFinish: null, - addedTime: 0, - pausedTime: 0, + ...initialTimer, pausedAt: null, - timeEnd: null, - startedAt: null, - finishedAt: null, - secondaryTimer: null, - selectedEventId: null, - duration: null, - timerType: null, - endAction: null, lastUpdate: null, secondaryTarget: null, - timeWarning: null, - timeDanger: null, get finishedNow() { return this.current <= 0 && this.finishedAt === null; }, @@ -56,31 +83,178 @@ export const state: TState = { }; export const stateMutations = { + load(event: OntimeEvent, rundown: OntimeEvent[], initialData?: Partial) { + mutate((state) => { + stateMutations.timer.clear(); + + const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id); + + state.runtime.selectedEventIndex = eventIndex; + state.runtime.selectedEventId = event.id; + state.runtime.numEvents = rundown.length; + + this.loadNow(event, rundown); + this.loadNext(rundown); + + state.timer.clock = clock.timeNow(); + state.playback = Playback.Armed; + state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); + state.timer.current = getCurrent(state); + + state.timer.timerType = event.timerType; + state.timer.endAction = event.endAction; + state.timer.timeWarning = event.timeWarning; + state.timer.timeDanger = event.timeDanger; + + 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; + state.runtime.selectedPublicEventId = event.id; + } else { + // assume there is no public event + state.publicEventNow = null; + state.runtime.selectedPublicEventId = 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]; + state.runtime.selectedPublicEventId = playableEvents[i].id; + break; + } + } + } + }); + }, + loadNext(playableEvents: OntimeEvent[]) { + mutate((state) => { + // assume there are no next events + state.eventNext = null; + state.publicEventNext = null; + state.runtime.nextEventId = null; + state.runtime.nextPublicEventId = 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]; + state.runtime.nextEventId = playableEvents[i].id; + nextProduction = true; + } + + // if event is public + if (playableEvents[i].isPublic) { + state.publicEventNext = playableEvents[i]; + state.runtime.nextPublicEventId = playableEvents[i].id; + nextPublic = true; + } + + // Stop if both are set + if (nextPublic && nextProduction) break; + } + } + }); + }, + resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeEvent[]) { + mutate((state) => { + const { playback, ...patch } = restorePoint; + + // TODO: this.load gets typed as any? + stateMutations.load(event, rundown, patch); + + // TODO: send as part of the patch when playback is merged to timer + state.playback = playback; + }); + }, + /** + * 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.expectedFinish = getExpectedFinish(state); + state.timer.timerType = state.eventNow.timerType; + state.timer.endAction = state.eventNow.endAction; + return; + } + state.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.timerType = state.eventNow.timerType; + state.timer.endAction = state.eventNow.endAction; + + 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.playback = Playback.Stop; - state.timer.clock = clock.timeNow(); - state.timer.current = null; - state.timer.elapsed = null; - state.timer.expectedFinish = null; - state.timer.addedTime = 0; - state.timer.startedAt = null; - state.timer.finishedAt = null; - state.timer.secondaryTimer = null; - state.timer.selectedEventId = null; - state.timer.duration = null; - state.timer.timerType = null; - state.timer.endAction = null; + state.eventNow = null; + state.publicEventNow = null; + state.eventNext = null; + state.publicEventNext = null; - state.timer.pausedTime = 0; - state.timer.pausedAt = null; - state.timer.secondaryTarget = null; + state.runtime = { ...initialRuntime }; + // TODO: could we avoid having this dependency? + state.runtime.numEvents = EventLoader.getPlayableEvents().length; + + state.playback = Playback.Stop; + + state.timer = { + ...initialTimer, + clock: clock.timeNow(), + pausedAt: null, + lastUpdate: null, + secondaryTarget: null, + finishedNow: false, + }; }); }, - // utility to allow modifying the state from the outside + /** utility to allow modifying the state from the outside */ patch(timer: Partial) { mutate((state) => { for (const key in timer) { @@ -93,31 +267,23 @@ export const stateMutations = { start() { mutate( (state) => { - // TODO: we need an event to start, should we get the event or the whole rundown? - state.timer.clock = clock.timeNow(); state.timer.secondaryTimer = null; state.timer.secondaryTarget = null; // add paused time if it exists - if (state.timer.pausedTime) { - state.timer.addedTime += state.timer.pausedTime; + if (state.timer.pausedAt) { + const timeToAdd = state.timer.clock - state.timer.pausedAt; + state.timer.addedTime += timeToAdd; state.timer.pausedAt = null; - state.timer.pausedTime = 0; - } else if (state.timer.startedAt === null) { + } + + if (state.timer.startedAt === null) { state.timer.startedAt = state.timer.clock; } state.playback = Playback.Play; - state.timer.expectedFinish = getExpectedFinish( - state.timer.startedAt, - state.timer.finishedAt, - state.timer.duration, - state.timer.pausedTime, - state.timer.addedTime, - state.timer.timeEnd, - state.timer.timerType, - ); + state.timer.expectedFinish = getExpectedFinish(state); }, { sideEffect() { @@ -141,14 +307,20 @@ export const stateMutations = { pause() { mutate( (state) => { - // TODO: is it easier to have a beforeAll() function that sets the timer? + if (state.playback !== Playback.Play) { + return false; + } + state.playback = Playback.Pause; state.timer.clock = clock.timeNow(); state.timer.pausedAt = state.timer.clock; + return true; }, { - sideEffect() { - integrationService.dispatch(TimerLifeCycle.onPause); + sideEffect(hasChanged: boolean) { + if (hasChanged) { + integrationService.dispatch(TimerLifeCycle.onPause); + } }, }, ); @@ -159,9 +331,8 @@ export const stateMutations = { // TODO: the duplication of timer data would not be necessary // once event loader is merged here - state.timer.selectedEventId = event.id; + state.runtime.selectedEventId = event.id; state.timer.startedAt = restorePoint.startedAt; - state.timer.timeEnd = event.timeEnd; state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); state.timer.current = state.timer.duration; @@ -174,98 +345,26 @@ export const stateMutations = { // check if event finished meanwhile if (state.timer.timerType === TimerType.TimeToEnd) { - state.timer.current = getCurrent( - state.timer.startedAt, - state.timer.duration, - 0, - 0, - state.timer.clock, - event.timeEnd, - state.timer.timerType, - ); - } - }); - }, - load(event: OntimeEvent, patch?: Partial) { - mutate( - (state) => { - // TODO: resume and load logic are very similar - state.timer.clock = clock.timeNow(); - state.timer.selectedEventId = event.id; - state.timer.timeEnd = event.timeEnd; - - state.playback = Playback.Armed; - state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); - state.timer.timerType = event.timerType; - state.timer.endAction = event.endAction; - state.timer.timeWarning = event.timeWarning; - state.timer.timeDanger = event.timeDanger; - state.timer.pausedTime = 0; - state.timer.pausedAt = 0; // TODO: should this not be null? - - state.timer.current = state.timer.duration; - if (state.timer.timerType === TimerType.TimeToEnd) { - state.timer.current = getCurrent( - state.timer.clock, - state.timer.duration, - 0, - 0, - state.timer.clock, - event.timeEnd, - state.timer.timerType, - ); - } - - if (patch) { - state.timer = { ...state.timer, ...patch }; - } - }, - { - sideEffect() { - integrationService.dispatch(TimerLifeCycle.onLoad); - }, - }, - ); - }, - reload(timer: OntimeEvent) { - mutate((state) => { - state.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd); - state.timer.timerType = timer.timerType; - state.timer.endAction = timer.endAction; - state.timer.timeEnd = timer.timeEnd; - - state.timer.finishedAt = null; - state.timer.expectedFinish = getExpectedFinish( - state.timer.startedAt, - state.timer.finishedAt, - state.timer.duration, - state.timer.pausedTime, - state.timer.addedTime, - state.timer.timeEnd, - state.timer.timerType, - ); - - if (state.timer.startedAt === null) { - state.timer.current = state.timer.duration; + state.timer.current = getCurrent(state); } }); }, addTime(amount: number) { mutate((state) => { - // TODO: what kinds of validation go here or in the consumer? - if (!state.timer.selectedEventId) { + // TODO: what kind of validation go here or in the consumer? + if (state.timer.startedAt === null) { return; } - // TODO: remove pausedTime in favour of addedTime 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; - if (willGoNegative) { - if (state.timer.finishedAt === null) { - state.timer.finishedAt = clock.timeNow(); - } + 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) { @@ -274,85 +373,45 @@ export const stateMutations = { } }); }, - // TODO: make options an object + // 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( - previousTime, - state.timer.clock, - state.timer.startedAt, - state.timer.expectedFinish, - timeSkipLimit, - ); + const hasSkippedOutOfEvent = skippedOutOfEvent(state, previousTime, timeSkipLimit); if (hasSkippedOutOfEvent) { return { doRoll: true }; } - - const tempCurrentTimer = { - selectedEventId: state.timer.selectedEventId, - current: state.timer.current, - // safeguard on midnight rollover - _finishAt: - state.timer.expectedFinish >= state.timer.startedAt - ? state.timer.expectedFinish - : state.timer.expectedFinish + dayInMs, - clock: state.timer.clock, - secondaryTimer: state.timer.secondaryTimer, - secondaryTarget: state.timer.secondaryTarget, - }; - - const updated = updateRoll(tempCurrentTimer); - const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updated; + const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(state); state.timer.current = updatedTimer; state.timer.secondaryTimer = updatedSecondaryTimer; state.timer.elapsed = state.timer.duration - state.timer.current; if (isFinished) { - state.timer.selectedEventId = null; + state.runtime.selectedEventId = null; } return { doRoll: doRollLoad, isFinished }; } function play() { - if (state.playback === Playback.Pause) { - state.timer.pausedTime = state.timer.clock - state.timer.pausedAt; - } - let isFinished = false; + state.timer.current = getCurrent(state); if (state.playback === Playback.Play && state.timer.finishedNow) { state.timer.finishedAt = state.timer.clock; isFinished = true; } else { - state.timer.expectedFinish = getExpectedFinish( - state.timer.startedAt, - state.timer.finishedAt, - state.timer.duration, - state.timer.pausedTime, - state.timer.addedTime, - state.timer.timeEnd, - state.timer.timerType, - ); + state.timer.expectedFinish = getExpectedFinish(state); } - state.timer.current = getCurrent( - state.timer.startedAt, - state.timer.duration, - state.timer.addedTime, - state.timer.pausedTime, - state.timer.clock, - state.timer.timeEnd, - state.timer.timerType, - ); - 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; @@ -362,8 +421,9 @@ export const stateMutations = { const previousTime = state.timer.clock; state.timer.clock = clock.timeNow(); + const hasSkippedBack = previousTime > state.timer.clock; - if (previousTime > state.timer.clock) { + if (hasSkippedBack) { _force = true; } @@ -388,7 +448,6 @@ export const stateMutations = { // TODO: can we simplify the didUpdate and shouldNotify _didUpdate = true; } - return { didUpdate: _didUpdate, doRoll: _doRoll, @@ -405,7 +464,7 @@ export const stateMutations = { } if (doRoll) { - PlaybackService.roll(); + runtimeService.roll(); } if (isFinished) { @@ -414,12 +473,12 @@ export const stateMutations = { // handle end action if there was a timer playing if (newState.playback === Playback.Play) { if (newState.timer.endAction === EndAction.Stop) { - PlaybackService.stop(); + runtimeService.stop(); } else if (newState.timer.endAction === EndAction.LoadNext) { // we need to delay here to put this action in the queue stack. otherwise it won't be executed properly - setTimeout(PlaybackService.loadNext, 0); + setTimeout(runtimeService.loadNext, 0); } else if (newState.timer.endAction === EndAction.PlayNext) { - PlaybackService.startNext(); + runtimeService.startNext(); } } } @@ -427,11 +486,11 @@ export const stateMutations = { }, ); }, - roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) { + roll(rundown: OntimeEvent[]) { mutate((state) => { stateMutations.timer.clear(); - // TODO: should we have a pre-action that updates time in all mutations? - state.timer.clock = clock.timeNow(); + + const { nextEvent, currentEvent } = getRollTimers(rundown, state.timer.clock); if (currentEvent) { // there is something running, load @@ -444,7 +503,7 @@ export const stateMutations = { // 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 - this.load(currentEvent, { + stateMutations.load(currentEvent, rundown, { startedAt: currentEvent.timeStart, expectedFinish: currentEvent.timeEnd, current: endTime - state.timer.clock, @@ -467,7 +526,7 @@ export const stateMutations = { * This function is the only way to write to the state. * Mock this in the unit tests to assert state transitions and side effects. */ -function mutate( +export function mutate( /** * A pure function that receives the current state and modifies it in place. * The function can return a value that will be passed to the side effects, and @@ -500,6 +559,11 @@ function mutate( // set eventStore any time a mutation happens // this means we are pushing this data to the client every 32ms eventStore.batchSet({ + eventNow: newState.eventNow, + publicEventNow: newState.publicEventNow, + eventNext: newState.eventNext, + publicEventNext: newState.publicEventNext, + loaded: newState.runtime, // TODO: rename to runtime playback: newState.playback, timer: newState.timer, }); @@ -507,7 +571,7 @@ function mutate( // we write to restore service if the underlying data changes restoreService.save({ playback: state.playback, - selectedEventId: state.timer.selectedEventId, + selectedEventId: state.runtime.selectedEventId, startedAt: state.timer.startedAt, addedTime: state.timer.addedTime, pausedAt: state.timer.pausedAt, diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts index d5fc15592..ae8a5dff1 100644 --- a/apps/server/src/stores/EventStore.ts +++ b/apps/server/src/stores/EventStore.ts @@ -1,9 +1,6 @@ -import { Playback, RuntimeStore } from 'ontime-types'; +import { RuntimeStore } from 'ontime-types'; import { socket } from '../adapters/WebsocketAdapter.js'; -import { messageService } from '../services/message-service/MessageService.js'; -import { eventLoader } from '../classes/event-loader/EventLoader.js'; -import { state } from '../state.js'; export type PublishFn = (key: T, value: RuntimeStore[T]) => void; @@ -48,34 +45,3 @@ export const eventStore = { }); }, }; - -/** - * Module initialises the services and provides initial payload for the store - * Currently registered objects in store - * - Timer Service timer - * - Timer Service playback - * - Timer Service onAir - * - Message Service timerMessage - * - Message Service publicMessage - * - Message Service lowerMessage - * - Event Loader loaded - * - Event Loader eventNow - * - Event Loader publicEventNow - * - Event Loader eventNext - * - Event Loader publicEventNext - */ - -export const getInitialPayload = () => ({ - timer: state.timer, - playback: state.playback, - onAir: state.playback !== Playback.Stop, - timerMessage: messageService.timerMessage, - publicMessage: messageService.publicMessage, - lowerMessage: messageService.lowerMessage, - externalMessage: messageService.externalMessage, - loaded: eventLoader.loaded, - eventNow: eventLoader.eventNow, - publicEventNow: eventLoader.publicEventNow, - eventNext: eventLoader.eventNext, - publicEventNext: eventLoader.publicEventNext, -}); diff --git a/apps/server/src/utils/__tests__/arrayUtils.tests.ts b/apps/server/src/utils/__tests__/arrayUtils.tests.ts index 947fc3a21..9d88353d5 100644 --- a/apps/server/src/utils/__tests__/arrayUtils.tests.ts +++ b/apps/server/src/utils/__tests__/arrayUtils.tests.ts @@ -1,4 +1,4 @@ -import { insertAtIndex, reorderArray } from '../arrayUtils.js'; +import { insertAtIndex, reorderArray, sortArrayByProperty } from '../arrayUtils.js'; describe('insertAtIndex', () => { it('should insert an item at the beginning of the array', () => { @@ -52,3 +52,37 @@ describe('reorderArray', () => { expect(result).toEqual(['b', 'c', 'a']); }); }); + +describe('sortArrayByProperty()', () => { + it('sort array 1-5', () => { + const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }]; + + const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }]; + + const sorted = sortArrayByProperty(arr1, 'timeStart'); + expect(sorted).toStrictEqual(arr1Expected); + }); + + it('sort array 1-5 with null', () => { + const arr1 = [ + { timeStart: 1 }, + { timeStart: 5 }, + { timeStart: 3 }, + { timeStart: 2 }, + { timeStart: 4 }, + { timeStart: null }, + ]; + + const arr1Expected = [ + { timeStart: null }, + { timeStart: 1 }, + { timeStart: 2 }, + { timeStart: 3 }, + { timeStart: 4 }, + { timeStart: 5 }, + ]; + + const sorted = sortArrayByProperty(arr1, 'timeStart'); + expect(sorted).toStrictEqual(arr1Expected); + }); +}); diff --git a/apps/server/src/utils/arrayUtils.ts b/apps/server/src/utils/arrayUtils.ts index 49cb8780a..a963ebddb 100644 --- a/apps/server/src/utils/arrayUtils.ts +++ b/apps/server/src/utils/arrayUtils.ts @@ -48,3 +48,16 @@ export function reorderArray(array: T[], fromIndex: number, toIndex: number) modifiedArray.splice(toIndex, 0, reorderedItem); return modifiedArray; } + +/** + * @description Sorts an array of objects by given property + * @param {array} arr - array to be sorted + * @param {string} property - property to compare + * @returns {array} copy of array sorted in ascending order + */ + +export const sortArrayByProperty = (arr: T[], property: string): T[] => { + return [...arr].sort((a, b) => { + return a[property] - b[property]; + }); +}; diff --git a/packages/types/src/definitions/runtime/Playlist.type.ts b/packages/types/src/definitions/runtime/Runtime.type.ts similarity index 89% rename from packages/types/src/definitions/runtime/Playlist.type.ts rename to packages/types/src/definitions/runtime/Runtime.type.ts index 2967ae555..b688df240 100644 --- a/packages/types/src/definitions/runtime/Playlist.type.ts +++ b/packages/types/src/definitions/runtime/Runtime.type.ts @@ -1,4 +1,4 @@ -export type Loaded = { +export type Runtime = { numEvents: number; selectedEventIndex: number | null; selectedEventId: string | null; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index 37d1c2d03..12be4a8d4 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -1,7 +1,7 @@ import { Playback } from './Playback.type.js'; import { Message, TimerMessage } from './MessageControl.type.js'; import { TimerState } from './TimerState.type.js'; -import { Loaded } from './Playlist.type.js'; +import { Runtime } from './Runtime.type.js'; import { OntimeEvent } from '../core/OntimeEvent.type.js'; export type RuntimeStore = { @@ -17,7 +17,7 @@ export type RuntimeStore = { onAir: boolean; // event loader - loaded: Loaded; + loaded: Runtime; eventNow: OntimeEvent | null; publicEventNow: OntimeEvent | null; eventNext: OntimeEvent | null; diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index fef68860c..a8f92a86e 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -10,7 +10,6 @@ export type TimerState = { startedAt: number | null; finishedAt: number | null; // only if timer has already finished secondaryTimer: number | null; // used for roll mode - selectedEventId: string | null; duration: number | null; timerType: TimerType | null; endAction: EndAction | null; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ec69dea1d..118c67f7f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -55,7 +55,7 @@ export { Playback } from './definitions/runtime/Playback.type.js'; export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js'; export type { Message, TimerMessage } from './definitions/runtime/MessageControl.type.js'; -export type { Loaded } from './definitions/runtime/Playlist.type.js'; +export type { Runtime } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export type { TimerState } from './definitions/runtime/TimerState.type.js'; diff --git a/packages/utils/src/date-utils/timeFormatting.test.ts b/packages/utils/src/date-utils/timeFormatting.test.ts index efa5398be..ba4f44167 100644 --- a/packages/utils/src/date-utils/timeFormatting.test.ts +++ b/packages/utils/src/date-utils/timeFormatting.test.ts @@ -1,5 +1,5 @@ -import { expect } from 'vitest'; - +import { dayInMs } from '../timeConstants'; +import { MILLIS_PER_HOUR } from './conversionUtils'; import { millisToString } from './timeFormatting'; describe('millisToString()', () => { @@ -22,8 +22,8 @@ describe('millisToString()', () => { { millis: -3600000, expected: '-01:00:00' }, { millis: -36000000, expected: '-10:00:00' }, { millis: -86399000, expected: '-23:59:59' }, - { millis: -86400000, expected: '-00:00:00' }, - { millis: -86401000, expected: '-00:00:01' }, + { millis: -86400000, expected: '-24:00:00' }, + { millis: -86401000, expected: '-24:00:01' }, ]; testScenarios.forEach((scenario) => { @@ -31,6 +31,10 @@ describe('millisToString()', () => { }); }); + it('handles times over 24 hours', () => { + expect(millisToString(dayInMs + MILLIS_PER_HOUR)).toBe('25:00:00'); + }); + test('random properties', () => { const testScenarios = [ { millis: 300, expected: '00:00:00' }, @@ -41,27 +45,8 @@ describe('millisToString()', () => { { millis: 3600000, expected: '01:00:00' }, { millis: 36000000, expected: '10:00:00' }, { millis: 86399000, expected: '23:59:59' }, - { millis: 86400000, expected: '00:00:00' }, - { millis: 86401000, expected: '00:00:01' }, - ]; - - testScenarios.forEach((scenario) => { - expect(millisToString(scenario.millis)).toBe(scenario.expected); - }); - }); - - test.skip('random properties without seconds', () => { - const testScenarios = [ - { millis: 300, expected: '00:00' }, - { millis: 1000, expected: '00:00' }, - { millis: 1500, expected: '00:00' }, - { millis: 60000, expected: '00:01' }, - { millis: 600000, expected: '00:10' }, - { millis: 3600000, expected: '01:00' }, - { millis: 36000000, expected: '10:00' }, - { millis: 86399000, expected: '23:59' }, - { millis: 86400000, expected: '00:00' }, - { millis: 86401000, expected: '00:00' }, + { millis: 86400000, expected: '24:00:00' }, + { millis: 86401000, expected: '24:00:01' }, ]; testScenarios.forEach((scenario) => { @@ -69,98 +54,3 @@ describe('millisToString()', () => { }); }); }); - -/** - * import { formatDisplay } from './formatDisplay'; - -describe('test string from formatDisplay function', () => { - it('test with null values', () => { - const t = { val: null, result: '00:00:00' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with not numbers', () => { - const t = { val: 'test', result: '00:00:00' }; - // @ts-expect-error -- indulge me for the test - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with valid millis', () => { - const t = { val: 3600000, result: '01:00:00' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with negative millis', () => { - const t = { val: -3600000, result: '01:00:00' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with 0', () => { - const t = { val: 0, result: '00:00:00' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with -0', () => { - const t = { val: -0, result: '00:00:00' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with 86400 (24 hours)', () => { - const t = { val: 86400000, result: '00:00:00' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with 86401 (24 hours and 1 second)', () => { - const t = { val: 86401000, result: '00:00:01' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); - - it('test with -86401 (-24 hours and 1 second)', () => { - const t = { val: -86401000, result: '00:00:01' }; - expect(formatDisplay(t.val, false)).toBe(t.result); - }); -}); - -describe('test string from formatDisplay function with hidezero', () => { - it('test with null values', () => { - const t = { val: null, result: '00:00' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with valid millis', () => { - const t = { val: 3600000, result: '01:00:00' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with negative millis', () => { - const t = { val: -3600000, result: '01:00:00' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with 0', () => { - const t = { val: 0, result: '00:00' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with -0', () => { - const t = { val: -0, result: '00:00' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with 86400 (24 hours)', () => { - const t = { val: 86400000, result: '00:00' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with 86401 (24 hours and 1 second)', () => { - const t = { val: 86401000, result: '00:01' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); - - it('test with -86401 (-24 hours and 1 second)', () => { - const t = { val: -86401000, result: '00:01' }; - expect(formatDisplay(t.val, true)).toBe(t.result); - }); -}); - - */ \ No newline at end of file diff --git a/packages/utils/src/date-utils/timeFormatting.ts b/packages/utils/src/date-utils/timeFormatting.ts index 200300aff..473f49204 100644 --- a/packages/utils/src/date-utils/timeFormatting.ts +++ b/packages/utils/src/date-utils/timeFormatting.ts @@ -25,7 +25,7 @@ export function millisToString(millis?: MaybeNumber, options?: FormatOptions): s const absoluteMillis = Math.abs(millis); const seconds = millisToSeconds(absoluteMillis) % 60; const minutes = millisToMinutes(absoluteMillis) % 60; - const hours = millisToHours(absoluteMillis) % 24; + const hours = millisToHours(absoluteMillis); const isNegative = millis < 0; diff --git a/packages/utils/src/validate-action/validatePlayback.ts b/packages/utils/src/validate-action/validatePlayback.ts index 6911c73ff..7212d2ace 100644 --- a/packages/utils/src/validate-action/validatePlayback.ts +++ b/packages/utils/src/validate-action/validatePlayback.ts @@ -1,10 +1,14 @@ import { Playback } from 'ontime-types'; +/** + * Simple rules to determine whether a playback action is valid + */ export function validatePlayback(currentPlayback: Playback) { return { - start: currentPlayback !== Playback.Stop, - pause: currentPlayback === Playback.Play || currentPlayback === Playback.Roll, - roll: true, + start: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Roll, + pause: currentPlayback === Playback.Play, + roll: currentPlayback !== Playback.Roll, stop: currentPlayback !== Playback.Stop, + reload: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Roll, }; }