From 3918758d32416a6daa06f4aef07f005e7d1ec683 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sat, 7 Jan 2023 22:44:21 +0100 Subject: [PATCH] v2: roll (#280) * ux: delete flow * style: small tweaks in interface * refactor: gracefully quit on error * fix: logic around updating events * chore: cleanup dictionary * refactor: improve DX on creating aux files * fix: safe destructure function return * refactor: safe handling of falsy timer values * fix: improve ux on stopping roll mode * chore: upgrade deps * feat: roll mode * refactor: remove unused --- .../features/control/playback/Transport.tsx | 2 +- .../rundown/event-block/EventBlock.tsx | 2 +- .../composite/EventBlockProgressBar.tsx | 2 +- client/src/theme/_ontimeColours.scss | 30 +- client/src/theme/ontimeButton.ts | 3 +- server/package.json | 2 +- server/src/app.js | 22 +- .../src/classes/data-provider/DataProvider.js | 7 +- .../src/classes/event-loader/EventLoader.js | 41 +- .../integrations => integration}/Http.js | 0 .../integrations => integration}/Osc.js | 0 .../__tests__/Osc.test.js | 0 server/src/classes/socket/SocketController.js | 7 +- server/src/classes/timer/EventTimer.js | 773 ------------------ server/src/classes/timer/Timer.js | 230 ------ .../timer/__tests__/eventtimer.test.js | 65 -- .../src/classes/timer/__tests__/timer.test.js | 54 -- server/src/controllers/OscController.js | 4 +- server/src/controllers/ontimeController.js | 2 +- server/src/controllers/playbackController.js | 2 +- server/src/controllers/rundownController.js | 2 +- server/src/modules/loadDb.js | 3 +- server/src/package.json | 2 +- ...{playbackService.js => PlaybackService.js} | 34 +- .../{rundownService.js => RundownService.js} | 9 +- server/src/services/TimerService.js | 114 ++- .../__tests__/rollUtils.test.js} | 62 +- .../classUtils.js => services/rollUtils.js} | 99 ++- server/src/utils/getRandomName.js | 3 - server/src/utils/time.js | 1 + server/src/yarn.lock | 18 +- 31 files changed, 305 insertions(+), 1290 deletions(-) rename server/src/classes/{timer/integrations => integration}/Http.js (100%) rename server/src/classes/{timer/integrations => integration}/Osc.js (100%) rename server/src/classes/{timer/integrations => integration}/__tests__/Osc.test.js (100%) delete mode 100644 server/src/classes/timer/EventTimer.js delete mode 100644 server/src/classes/timer/Timer.js delete mode 100644 server/src/classes/timer/__tests__/eventtimer.test.js delete mode 100644 server/src/classes/timer/__tests__/timer.test.js rename server/src/services/{playbackService.js => PlaybackService.js} (81%) rename server/src/services/{rundownService.js => RundownService.js} (96%) rename server/src/{classes/timer/__tests__/classUtils.test.js => services/__tests__/rollUtils.test.js} (90%) rename server/src/{classes/timer/classUtils.js => services/rollUtils.js} (70%) diff --git a/client/src/features/control/playback/Transport.tsx b/client/src/features/control/playback/Transport.tsx index d20431fa6..6cff7e1db 100644 --- a/client/src/features/control/playback/Transport.tsx +++ b/client/src/features/control/playback/Transport.tsx @@ -51,7 +51,7 @@ export default function Transport(props: TransportProps) { setPlayback.stop()} - disabled={!selectedId} + disabled={!selectedId && !isRolling} theme='stop' > diff --git a/client/src/features/rundown/event-block/EventBlock.tsx b/client/src/features/rundown/event-block/EventBlock.tsx index c32789484..1196e97b5 100644 --- a/client/src/features/rundown/event-block/EventBlock.tsx +++ b/client/src/features/rundown/event-block/EventBlock.tsx @@ -246,7 +246,7 @@ export default function EventBlock(props: EventBlockProps) { showDelay showBlock showClone - enableDelete + enableDelete={!selected} actionHandler={actionHandler} /> diff --git a/client/src/features/rundown/event-block/composite/EventBlockProgressBar.tsx b/client/src/features/rundown/event-block/composite/EventBlockProgressBar.tsx index 619f17f57..37fc124ce 100644 --- a/client/src/features/rundown/event-block/composite/EventBlockProgressBar.tsx +++ b/client/src/features/rundown/event-block/composite/EventBlockProgressBar.tsx @@ -16,7 +16,7 @@ export default function EventBlockProgressBar(props: EventBlockProgressBarProps) const elapsed = clamp(100 - (now * 100) / complete, 0, 100); const progress = `${elapsed}%`; - if (timer?.current != null && timer?.current < 0) { + if ((timer?.current ?? 0) < 0) { return (
src/version.js", "nodestart": "NODE_ENV=development node src/app.js", "setdb": "cp demo-db/db.json src/preloaded-db/db.json", diff --git a/server/src/app.js b/server/src/app.js index 8fc7324ab..3ea25a95c 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -156,20 +156,34 @@ export const startIntegrations = async (overrideConfig = null) => { /** * @description clean shutdown app services + * @param {number} exitCode * @return {Promise} */ -export const shutdown = async () => { +export const shutdown = async (exitCode) => { // shutdown express server server.close(); shutdownOSCServer(); eventTimer.shutdown(); socket.shutdown(); + process.exit(exitCode || 0); }; +process.on('unhandledRejection', async (error, promise) => { + console.error(error, 'Error: unhandled rejection', promise); + socket.error('SERVER', 'Error: unhandled rejection'); + await shutdown(1); +}); + +process.on('uncaughtException', async (error, promise) => { + console.error(error, 'Error: uncaught exception', promise); + socket.error('SERVER', 'Error: uncaught exception'); + await shutdown(1); +}); + // register shutdown signals -process.once('SIGHUP', shutdown); -process.once('SIGINT', shutdown); -process.once('SIGTERM', shutdown); +process.once('SIGHUP', async () => shutdown(0)); +process.once('SIGINT', async () => shutdown(0)); +process.once('SIGTERM', async () => shutdown(0)); export { server, app }; diff --git a/server/src/classes/data-provider/DataProvider.js b/server/src/classes/data-provider/DataProvider.js index 393c770b1..ba0fff697 100644 --- a/server/src/classes/data-provider/DataProvider.js +++ b/server/src/classes/data-provider/DataProvider.js @@ -30,9 +30,10 @@ export class DataProvider { static async updateEventById(eventId, newData) { const eventIndex = data.rundown.findIndex((e) => e.id === eventId); - const e = data.rundown[eventIndex]; - data.rundown[eventIndex] = { ...e, ...newData }; - data.rundown[eventIndex].revision++; + const persistedEvent = data.rundown[eventIndex]; + const newEvent = { ...persistedEvent, ...newData }; + newEvent.revision++; + data.rundown[eventIndex] = newEvent; await this.persist(); return data.rundown[eventIndex]; } diff --git a/server/src/classes/event-loader/EventLoader.js b/server/src/classes/event-loader/EventLoader.js index d8552fca0..38af16acf 100644 --- a/server/src/classes/event-loader/EventLoader.js +++ b/server/src/classes/event-loader/EventLoader.js @@ -1,6 +1,5 @@ import { DataProvider } from '../data-provider/DataProvider.js'; -import { getSelectionByRoll } from '../timer/classUtils.js'; -import { Timer } from '../timer/Timer.js'; +import { getRollTimers } from '../../services/rollUtils.js'; let instance; @@ -136,12 +135,36 @@ export class EventLoader { /** * finds next event within Roll context - * @returns {{nowIndex: null, timers: null, nowId: null, publicNextIndex: null, nextIndex: null, timeToNext: null, publicIndex: null}|{nowIndex: null, timers: null, nowId: null, publicNextIndex: null, nextIndex: null, timeToNext: null, publicIndex: null}} + * @param {number} timeNow - current time in ms */ - findRoll() { + findRoll(timeNow) { const timedEvents = EventLoader.getPlayableEvents(); - const millisNow = Timer.getCurrentTime(); - return getSelectionByRoll(timedEvents, millisNow); + if (!timedEvents.length) { + return null; + } + + const { + nowIndex, + timers, + timeToNext, + nextEvent, + nextPublicEvent, + currentEvent, + currentPublicEvent, + } = getRollTimers(timedEvents, timeNow); + + this.loadedEvent = currentEvent; + this.selectedEventIndex = nowIndex; + this.selectedEventId = currentEvent?.id || null; + this.numEvents = timedEvents.length; + + // titles + this._loadThisTitles(currentEvent, 'now-private'); + this._loadThisTitles(currentPublicEvent, 'now-public'); + this._loadThisTitles(nextEvent, 'next-private'); + this._loadThisTitles(nextPublicEvent, 'next-public'); + + return { currentEvent, nextEvent, timeToNext, timers }; } /** @@ -300,6 +323,10 @@ export class EventLoader { * @private */ _loadThisTitles(event, type) { + if (!event) { + return; + } + switch (type) { // now, load to both public and private case 'now': @@ -364,7 +391,7 @@ export class EventLoader { break; default: - break; + throw new Error(`Unhandled title type: ${type}`); } } } diff --git a/server/src/classes/timer/integrations/Http.js b/server/src/classes/integration/Http.js similarity index 100% rename from server/src/classes/timer/integrations/Http.js rename to server/src/classes/integration/Http.js diff --git a/server/src/classes/timer/integrations/Osc.js b/server/src/classes/integration/Osc.js similarity index 100% rename from server/src/classes/timer/integrations/Osc.js rename to server/src/classes/integration/Osc.js diff --git a/server/src/classes/timer/integrations/__tests__/Osc.test.js b/server/src/classes/integration/__tests__/Osc.test.js similarity index 100% rename from server/src/classes/timer/integrations/__tests__/Osc.test.js rename to server/src/classes/integration/__tests__/Osc.test.js diff --git a/server/src/classes/socket/SocketController.js b/server/src/classes/socket/SocketController.js index ab3493229..7cd622a47 100644 --- a/server/src/classes/socket/SocketController.js +++ b/server/src/classes/socket/SocketController.js @@ -3,12 +3,11 @@ import { Server } from 'socket.io'; import getRandomName from '../../utils/getRandomName.js'; import { generateId } from '../../utils/generate_id.js'; import { stringFromMillis } from '../../utils/time.js'; -import { Timer } from '../timer/Timer.js'; import { messageManager } from '../message-manager/MessageManager.js'; -import { PlaybackService } from '../../services/playbackService.js'; +import { PlaybackService } from '../../services/PlaybackService.js'; import { ADDRESS_MESSAGE_CONTROL } from './socketConfig.js'; -import { eventTimer } from '../../services/TimerService.js'; +import { eventTimer, TimerService } from '../../services/TimerService.js'; import { EventLoader, eventLoader } from '../event-loader/EventLoader.js'; class SocketController { @@ -342,7 +341,7 @@ class SocketController { level, origin, text, - time: stringFromMillis(Timer.getCurrentTime() || 0), + time: stringFromMillis(TimerService.getCurrentTime() || 0), }; this.messageStack.unshift(logMessage); diff --git a/server/src/classes/timer/EventTimer.js b/server/src/classes/timer/EventTimer.js deleted file mode 100644 index ed6e29fa9..000000000 --- a/server/src/classes/timer/EventTimer.js +++ /dev/null @@ -1,773 +0,0 @@ -import { Timer } from './Timer.js'; -import { DAY_TO_MS, replacePlaceholder, updateRoll } from './classUtils.js'; -import { OSCIntegration } from './integrations/Osc.js'; -import { HTTPIntegration } from './integrations/Http.js'; -import { cleanURL } from '../../utils/url.js'; -import { EventLoader, eventLoader } from '../event-loader/EventLoader.js'; - -/* - * Class EventTimer adds functions specific to APP - * @extends Timer - */ -export class EventTimer extends Timer { - /** - * Instantiates an event timer object - * @param {object} socket - * @param {object} timerConfig - * @param {object} [oscConfig] - * @param {object} [httpConfig] - */ - constructor(socket, timerConfig, oscConfig, httpConfig) { - // call super constructor - super(); - - this.cycleState = { - /* idle: before it is initialised */ - idle: 'idle', - /* onLoad: when a new event is loaded */ - onLoad: 'onLoad', - /* armed: when a new event is loaded but hasn't started */ - armed: 'armed', - onStart: 'onStart', - /* update: every update call cycle (1 x second) */ - onUpdate: 'onUpdate', - onPause: 'onPause', - onStop: 'onStop', - onFinish: 'onFinish', - }; - this.ontimeCycle = 'idle'; - this.prevCycle = null; - - // Socket Object - this.socket = socket; - - // OSC Object - this.osc = null; - - // HTTP Client Object - this.http = null; - - // call general title reset - this._resetSelection(); - - // set recurrent emits - this._interval = setInterval(() => this.runCycle(), timerConfig?.refresh || 1000); - - if (oscConfig != null) { - this._initOscClient(oscConfig); - } - - if (httpConfig != null) { - this._initHTTPClient(httpConfig); - } - } - - /** - * @description Shutdown process - */ - shutdown() { - clearInterval(this._interval); - if (this.osc != null) { - this.socket.info('TX', '... Closing OSC Client'); - this.osc.shutdown(); - } - if (this.http != null) { - this.socket.info('TX', '... Closing HTTP Client'); - this.http.shutdown(); - } - } - - /** - * Initialises OSC Integration object - * @param {object} oscConfig - * @private - */ - _initOscClient(oscConfig) { - this.osc = new OSCIntegration(); - const r = this.osc.init(oscConfig); - r.success ? this.socket.info('TX', r.message) : this.socket.error('TX', r.message); - } - - /** - * Initialises HTTP Integration object - * @param {object} httpConfig - * @private - */ - _initHTTPClient(httpConfig) { - this.socket.info('TX', `Initialise HTTP Client on port`); - this.http = new HTTPIntegration(); - this.http.init(httpConfig); - this.httpMessages = httpConfig.messages; - } - - /** - * Sends time object over websockets - */ - broadcastTimer() { - // through websockets - this.socket.send('timer', this.getTimeObject()); - } - - /** - * @description Broadcast timer data - * @private - */ - _broadcastFeatureTimer() { - const featureData = { - clock: this.clock, - current: this.current, - secondaryTimer: this.secondaryTimer, - duration: this.duration, - startedAt: this._startedAt, - expectedFinish: this._getExpectedFinish(), - }; - this.socket.send('ontime-timer', featureData); - } - - /** - * @description Broadcast data for Event List feature - * @private - */ - _broadcastFeatureRundown() { - const featureData = { - selectedEventId: this.selectedEventId, - nextEventId: this.nextEventId, - playback: this.state, - }; - this.socket.send('feat-rundown', featureData); - } - - /** - * @description Broadcast data for Playback Control feature - * @private - */ - _broadcastFeaturePlaybackControl() { - const numEvents = EventLoader.getNumEvents(); - const featureData = { - playback: this.state, - selectedEventId: this.selectedEventId, - numEvents: numEvents, - }; - this.socket.send('feat-playbackcontrol', featureData); - } - - /** - * @description Broadcast data for Info feature - * @private - */ - _broadcastFeatureInfo() { - const numEvents = EventLoader.getNumEvents(); - const featureData = { - titles: this.titles, - playback: this.state, - selectedEventId: this.selectedEventId, - selectedEventIndex: this.selectedEventIndex, - numEvents: numEvents, - }; - this.socket.send('feat-info', featureData); - } - - _broadcastFeatureCuesheet() { - const numEvents = EventLoader.getNumEvents(); - const featureData = { - playback: this.state, - selectedEventId: this.selectedEventId, - selectedEventIndex: this.selectedEventIndex, - numEvents: numEvents, - titleNow: this.titles.titleNow, - }; - this.socket.send('feat-cuesheet', featureData); - } - - /** - * Broadcasts complete object state - */ - broadcastState() { - // feature sync - this._broadcastFeatureRundown(); - this._broadcastFeaturePlaybackControl(); - this._broadcastFeatureInfo(); - this._broadcastFeatureCuesheet(); - this._broadcastFeatureTimer(); - - this.broadcastTimer(); - this.socket.send('playstate', this.state); - this.socket.send('selected-id', this.selectedEventId); - this.socket.send('next-id', this.nextEventId); - this.socket.send('publicselected-id', this.selectedPublicEventId); - this.socket.send('publicnext-id', this.nextPublicEventId); - this.socket.send('titles', this.titles); - this.socket.send('publictitles', this.titlesPublic); - } - - /** - * @description State machine checks what actions need to - * happen at every app cycle - */ - runCycle() { - const h = this.httpMessages?.messages; - let httpMessage = null; - - switch (this.ontimeCycle) { - case 'idle': - break; - case 'armed': - // if we come from roll, see if we can start - if (this.state === 'roll') { - this.update(); - } - break; - case 'onLoad': - // check integrations - http - if (h?.onLoad?.enabled) { - if (h?.onLoad?.url != null || h?.onLoad?.url !== '') { - httpMessage = h?.onLoad?.url; - } - } - - // update lifecycle: armed - this.ontimeCycle = this.cycleState.armed; - break; - case 'onStart': - // send OSC if there is something running - // _finish at is only set when an event is loaded - if (this._finishAt > 0) { - this.sendOsc(this.osc.implemented.play); - this.sendOsc(this.osc.implemented.eventNumber, this.selectedEventIndex || 0); - } - // check integrations - http - if (h?.onLoad?.enabled) { - if (h?.onLoad?.url != null || h?.onStart?.url !== '') { - httpMessage = h?.onStart?.url; - } - } - - // update lifecycle: onUpdate - this.ontimeCycle = this.cycleState.onUpdate; - break; - case 'onUpdate': - // call update - this.update(); - // through OSC, only if running - if (this.state === 'start' || this.state === 'roll') { - if (this.current != null && this.secondaryTimer == null) { - this.sendOsc(this.osc.implemented.time, this.timeTag); - this.sendOsc(this.osc.implemented.overtime, this.current > 0 ? 0 : 1); - this.sendOsc(this.osc.implemented.title, this.titles?.titleNow || ''); - this.sendOsc(this.osc.implemented.presenter, this.titles?.presenterNow || ''); - } - } - - // check integrations - http - if (h?.onLoad?.enabled) { - if (h?.onLoad?.url != null || h?.onUpdate?.url !== '') { - httpMessage = h?.onUpdate?.url; - } - } - - break; - case 'onPause': - // send OSC - if (this.prevCycle === this.cycleState.onUpdate) { - this.sendOsc(this.osc.implemented.pause); - } - - // check integrations - http - if (h?.onLoad?.enabled) { - if (h?.onLoad?.url != null || h?.onPause?.url !== '') { - httpMessage = h?.onPause?.url; - } - } - - // update lifecycle: armed - this.ontimeCycle = this.cycleState.armed; - - break; - case 'onStop': - // send OSC if something was actually stopped - if (this.prevCycle === this.cycleState.onUpdate) { - this.sendOsc(this.osc.implemented.stop); - } - - // check integrations - http - if (h?.onLoad?.enabled) { - if (h?.onLoad?.url != null || h?.onStop?.url !== '') { - httpMessage = h?.onStop?.url; - } - } - - // update lifecycle: idle - this.ontimeCycle = this.cycleState.idle; - break; - case 'onFinish': - // finished an event - this.sendOsc(this.osc.implemented.finished); - - // check integrations - http - if (h?.onLoad?.enabled) { - if (h?.onLoad?.url != null || h?.onFinish?.url !== '') { - httpMessage = h?.onFinish?.url; - } - } - - // update lifecycle: onUpdate - this.ontimeCycle = this.cycleState.onUpdate; - break; - default: - this.socket.error('SERVER', `Unhandled cycle: ${this.ontimeCycle}`); - } - - // send http message if any - if (httpMessage != null) { - const v = { - $timer: this.timeTag, - $title: this.titles.titleNow, - $presenter: this.titles.presenterNow, - $subtitle: this.titles.subtitleNow, - '$next-title': this.titles.titleNext, - '$next-presenter': this.titles.presenterNext, - '$next-subtitle': this.titles.subtitleNext, - }; - const m = cleanURL(replacePlaceholder(httpMessage, v)); - this.http.send(m); - } - - // update - this.update(); - this.broadcastState(); - - // reset cycle - this.prevCycle = this.ontimeCycle; - } - - update() { - // if there is nothing selected, update clock - this.clock = Timer.getCurrentTime(); - this._broadcastFeatureTimer(); - this.broadcastTimer(); - - // if we are not updating, send the timers - if (this.ontimeCycle !== this.cycleState.onUpdate) { - this.socket.send('timer', this.getTimeObject()); - } - - // Have we skipped onStart? - if (this.state === 'start' || this.state === 'roll') { - if (this.ontimeCycle === this.cycleState.armed) { - // update lifecycle: onStart - this.ontimeCycle = this.cycleState.onStart; - this.runCycle(); - } - } - - // update default functions - super.update(); - - if (this._finishedFlag) { - // update lifecycle: onFinish and call cycle - - this.ontimeCycle = this.cycleState.onFinish; - this._finishedFlag = false; - this.runCycle(); - } - - // only implement roll here, rest implemented in super - if (this.state === 'roll') { - const u = { - selectedEventId: this.selectedEventId, - current: this.current, - // safeguard on midnight rollover - _finishAt: this._finishAt >= this._startedAt ? this._finishAt : this._finishAt + DAY_TO_MS, - clock: this.clock, - secondaryTimer: this.secondaryTimer, - _secondaryTarget: this._secondaryTarget, - }; - - const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(u); - - this.current = updatedTimer; - this.secondaryTimer = updatedSecondaryTimer; - - if (isFinished) { - // update lifecycle: onFinish - this.ontimeCycle = this.cycleState.onFinish; - this.runCycle(); - } - - if (doRollLoad) { - this.rollLoad(); - } - } - } - - /** - * - * @param {string} eventId - */ - syncLoaded(eventId) { - if (this.state === 'roll') { - this.rollLoad(); - } else { - const event = EventLoader.getEventWithId(eventId); - this.loadEvent(event, 'reload'); - } - } - - /** - * Loads a given event by index - * @typedef ('load'|'reload') loadEventOptions - * @param {object} event - * @param {string} [type='load'] - 'load' or 'reload', whether we are keeping running time - */ - loadEvent(event, type = 'load') { - const loadedData = eventLoader.loadById(event.id); - if (!loadedData) { - return; - } - - const { loadedEvent, selectedEventIndex, selectedEventId, nextEventId, titles, titlesPublic } = - loadedData; - - const start = loadedEvent.timeStart || 0; - let end = loadedEvent.timeEnd || 0; - - // in case the end is earlier than start, we assume is the day after - if (end < start) { - end += DAY_TO_MS; - } - - this.duration = end - start; - this.selectedEventIndex = selectedEventIndex; - this.selectedEventId = selectedEventId; - this.nextEventId = nextEventId; - - if (type === 'load') { - this._resetTimers(); - this.current = this.duration; - } else { - const now = Timer.getCurrentTime(); - const elapsed = this.getElapsed(); - this._finishAt = now + (this.duration - elapsed); - } - - this.titles = titles; - this.titlesPublic = titlesPublic; - - // update lifecycle: onLoad - this.ontimeCycle = this.cycleState.onLoad; - } - - /** - * @description resets selected event data - * @private - */ - _resetSelection() { - this.titles = { - titleNow: null, - subtitleNow: null, - presenterNow: null, - noteNow: null, - titleNext: null, - subtitleNext: null, - presenterNext: null, - noteNext: null, - }; - - this.titlesPublic = { - titleNow: null, - subtitleNow: null, - presenterNow: null, - titleNext: null, - subtitleNext: null, - presenterNext: null, - }; - - this.selectedEventIndex = null; - this.selectedEventId = null; - this.nextEventId = null; - this.selectedPublicEventId = null; - this.nextPublicEventId = null; - } - - /** - * @description start timer - * @return {('start'|'pause'|'stop'|'roll')} Playback state - */ - start() { - // do we need to change - if (this.state === 'start') return 'start'; - - // call super - super.start(); - - // update lifecycle: onStart - this.ontimeCycle = this.cycleState.onStart; - - return this.state; - } - - /** - * @description pause timer - * @return {('start'|'pause'|'stop')} Playback state - */ - pause() { - // do we need to change - if (this.state === 'pause') return 'pause'; - - // call super - super.pause(); - - // update lifecycle: onPause - this.ontimeCycle = this.cycleState.onPause; - - return this.state; - } - - /** - * @description stop timer - * @return {('start'|'pause'|'stop'|'roll')} Playback state - */ - stop() { - // do we need to change - if (this.state === 'stop') return 'stop'; - - // call super - super.stop(); - this._resetTimers(true); - this._resetSelection(); - - // update lifecycle: onStop - this.ontimeCycle = this.cycleState.onStop; - - // broadcast state - this.broadcastState(); - - return this.state; - } - - /** - * @description increment timer by amount - * @param amount - */ - increment(amount) { - // call super - super.increment(amount); - - // run cycle - this.runCycle(); - } - - /** - * @description Look for current event considering local clock - */ - rollLoad() { - const prevLoaded = this.selectedEventId; - - // maybe roll has already been loaded - if (this.secondaryTimer === null) { - this._resetTimers(true); - this._resetSelection(); - } - - const { nowIndex, nowId, publicIndex, nextIndex, publicNextIndex, timers, timeToNext } = - eventLoader.findRoll(); - - // nothing to play, unload - if (nowIndex === null && nextIndex === null) { - this.stop(); - this.socket.warning('SERVER', 'Roll: no events found'); - return; - } - - // there is something running, load - if (nowIndex !== null) { - // clear secondary timers - this.secondaryTimer = null; - this._secondaryTarget = null; - - // set timers - this._startedAt = timers._startedAt; - this._finishAt = timers._finishAt; - this.duration = timers.duration; - this.current = timers.current; - - // set selection - this.selectedEventId = nowId; - this.selectedEventIndex = nowIndex; - } - - // found something to run next - if (nextIndex != null) { - const eventNext = EventLoader.getPlayableAtIndex(nextIndex); - - // Set running timers - if (nowIndex === null) { - // only warn the first time - if (this.secondaryTimer === null) { - this.socket.info('SERVER', 'Roll: waiting for event start'); - } - - // reset running timer - // ??? should this not have been reset? - this.current = null; - - // timer counts to next event - this.secondaryTimer = timeToNext; - this._secondaryTarget = eventNext.timeStart; - } - - // TITLES: Load next private - // Todo: this should be an ID - // todo: this logic should be removed - if (eventNext) { - this.titles.titleNext = eventNext.title; - this.titles.subtitleNext = eventNext.subtitle; - this.titles.presenterNext = eventNext.presenter; - this.titles.noteNext = eventNext.note; - this.nextEventId = eventNext.id; - } - } - - // Todo: this should be an ID - // todo: this logic should be removed - // TITLES: Load next public - if (publicNextIndex !== null) { - const eventNextPublic = EventLoader.getPlayableAtIndex(publicNextIndex); - if (eventNextPublic) { - this.titlesPublic.titleNext = eventNextPublic.title; - this.titlesPublic.subtitleNext = eventNextPublic.subtitle; - this.titlesPublic.presenterNext = eventNextPublic.presenter; - this.titlesPublic.noteNext = eventNextPublic.note; - this.nextPublicEventId = eventNextPublic.id; - } - } - - // Todo: this should be an ID - // todo: this logic should be removed - // TITLES: Load now private - if (nowIndex !== null) { - const eventNowPrivate = EventLoader.getPlayableAtIndex(nowIndex); - if (eventNowPrivate) { - this.titles.titleNow = eventNowPrivate.title; - this.titles.subtitleNow = eventNowPrivate.subtitle; - this.titles.presenterNow = eventNowPrivate.presenter; - this.titles.noteNow = eventNowPrivate.note; - this.selectedEventId = eventNowPrivate.id; - } - } - - // Todo: this should be an ID - // todo: this logic should be removed - // TITLES: Load now public - if (publicIndex !== null) { - const eventNowPublic = EventLoader.getPlayableAtIndex(nowIndex); - if (eventNowPublic) { - this.titlesPublic.titleNow = eventNowPublic.title; - this.titlesPublic.subtitleNow = eventNowPublic.subtitle; - this.titlesPublic.presenterNow = eventNowPublic.presenter; - this.titlesPublic.noteNow = eventNowPublic.note; - this.selectedPublicEventId = eventNowPublic.id; - } - } - - if (prevLoaded !== this.selectedEventId) { - // update lifecycle: onLoad - this.ontimeCycle = this.cycleState.onLoad; - // ensure we go through onLoad cycle - this.runCycle(); - } - } - - /** - * Starts roll mode - * @return {('start'|'pause'|'stop'|'roll')} Playback state - */ - roll() { - if (this.state === 'roll') { - return 'roll'; - } - - this.state = 'roll'; - - // update lifecycle: armed - this.ontimeCycle = this.cycleState.armed; - - // load into event - this.rollLoad(); - - return this.state; - } - - previous() { - this.sendOsc(this.osc.implemented.previous); - this.pause(); - this.runCycle(); - } - - next() { - this.sendOsc(this.osc.implemented.next); - this.pause(); - this.runCycle(); - } - - /** - * @description reloads current event - * @return {('start'|'pause'|'stop'|'roll')} Playback state - */ - reload() { - if (!this.selectedEventId) { - return this.state; - } - - // change playstate - this.pause(); - - // send OSC - this.sendOsc(this.osc.implemented.reload); - - // reload data - const event = EventLoader.getEventWithId(this.selectedEventId); - this.loadEvent(event); - - this.runCycle(); - - return this.state; - } - - /****************************************************************************/ - - /** - * Integrations - * ------------- - * - * Code related to integrations - * - */ - - /** - * Calls OSC send message and resolves reply to logger - * @param {string} message - * @param {any} [payload] - */ - async sendOsc(message, payload) { - const reply = await this.osc.send(message, payload); - if (!reply.success) { - this.socket.error('TX', reply.message); - } - } - - /** - * @description Builds sync object - */ - poll() { - return { - currentId: this.selectedEventId, - timer: this.timeTag, - clock: this.clock, - playback: this.state, - currentColour: null, - title: this.titles.titleNow, - presenter: this.titles.presenterNow, - }; - } -} diff --git a/server/src/classes/timer/Timer.js b/server/src/classes/timer/Timer.js deleted file mode 100644 index fc8aeb704..000000000 --- a/server/src/classes/timer/Timer.js +++ /dev/null @@ -1,230 +0,0 @@ -import { stringFromMillis } from '../../utils/time.js'; - -/** - * @description Implements simple countdown timer functions - * @class - */ -export class Timer { - constructor() { - this.clock = null; - this._resetTimers(true); - this.state = 'stop'; - } - - /** - * @description updates the running timer - */ - update() { - // get current time - const now = Timer.getCurrentTime(); - this.clock = now; - let checkFinish = false; - - // check playstate - switch (this.state) { - case 'start': - // ensure we have a start time - if (this._startedAt == null) this._startedAt = now; - - // update current timer - this.current = this._startedAt + this.duration + this._pausedTotal - now; - - // enable flag - checkFinish = true; - break; - case 'pause': - // update paused time - this._pausedInterval = now - this._pausedAt; - - if (this._startedAt != null) { - // update current timer - this.current = - this._startedAt + this.duration + this._pausedTotal + this._pausedInterval - now; - } - - // enable flag - checkFinish = true; - break; - case 'stop': - // nothing here yet - break; - } - - if (checkFinish) { - // is event finished? - const isTimeOver = this.current <= 0; - const isUpdating = this.state !== 'pause'; - - if (isTimeOver && isUpdating && this._finishedAt == null) { - if (this._finishedAt === null) this._finishedAt = now; - this._finishedFlag = true; - } - } - this.timeTag = stringFromMillis(this.current); - } - - // helpers - /** - * @description converts a value in millis to seconds - * @param millis - * @return {number} - */ - static toSeconds(millis) { - if (millis == null) return 0; - return millis < 0 ? Math.ceil(millis * 0.001) : Math.floor(millis * 0.001); - } - - /** - * @description get current time in ms from midnight - * @return {number} - */ - static getCurrentTime() { - const now = new Date(); - - // extract milliseconds since midnight - let elapsed = now.getHours() * 3600000; - elapsed += now.getMinutes() * 60000; - elapsed += now.getSeconds() * 1000; - elapsed += now.getMilliseconds(); - - return elapsed; - } - - /** - * @description when is timer finishing - * @return {null|*|null|number} - * @private - */ - _getExpectedFinish() { - if (this._startedAt == null) return null; - if (this._finishedAt) return this._finishedAt; - - return Math.max( - this._startedAt + this.duration + this._pausedInterval + this._pausedTotal, - this._startedAt - ); - } - - /** - * @description resets timer parameters - * @param total - * @private - */ - _resetTimers(total = false) { - if (total) this.duration = null; - this.current = this.duration; - this.timeTag = null; - this.running = null; - this.secondaryTimer = null; - this._secondaryTarget = null; - this._finishAt = null; - this._finishedAt = null; - this._finishedFlag = false; - this._startedAt = null; - this._pausedAt = null; - this._pausedInterval = null; - this._pausedTotal = null; - } - - /** - * @description get elapsed time - * @return {number} - */ - getElapsed() { - return this.duration - this.current; - } - - /** - * @description Builds time object - * @returns {{running: number, secondary: number, expectedFinish: number, durationSeconds: number, startedAt: null, clock: null}} - */ - getTimeObject() { - return { - clock: this.clock, - isNegative: this.current < 0, - running: Timer.toSeconds(this.current), - secondary: Timer.toSeconds(this.secondaryTimer), - durationSeconds: Timer.toSeconds(this.duration), - expectedFinish: this._getExpectedFinish(), - startedAt: this._startedAt, - }; - } - - // playback - /** - * @description start current time - */ - start() { - // do we need to change - if (this.state === 'start') return; - else if (this._startedAt == null) { - // it hasn't started yet - const now = Timer.getCurrentTime(); - // set start time as now - this._startedAt = now; - // calculate expected finish time - this._finishAt = now + this.duration; - // reset pauses - this._pausedTotal = null; - this._pausedInterval = null; - } else { - // check if there is paused time - if (this._pausedInterval) { - this._pausedTotal += this._pausedInterval; - this._pausedInterval = null; - } - } - - // change state - this.state = 'start'; - } - - /** - * @description pause current timer - */ - pause() { - // do we need to change - if (this.state === 'pause') return; - - if (this._pausedInterval) { - this._pausedTotal += this._pausedInterval; - this._pausedInterval = null; - } - - // set pause time - this._pausedAt = Timer.getCurrentTime(); - - // change state - this.state = 'pause'; - } - - /** - * @description stop current timer - */ - stop() { - // do we need to change - if (this.state === 'stop') return; - - // clear all timers - this._resetTimers(); - - // change state - this.state = 'stop'; - } - - /** - * @description increments a given amout to the timer - * @param amount - */ - increment(amount) { - this.duration += amount; - - if (amount < 0 && Math.abs(amount) > this.current) { - // if we will make the clock negative - if (this._finishedAt == null) this._finishedAt = Timer.getCurrentTime(); - } else if (this.current < 0 && this.current + amount > 0) { - // clock will go from negative to positive - this._finishedAt = null; - } - } -} diff --git a/server/src/classes/timer/__tests__/eventtimer.test.js b/server/src/classes/timer/__tests__/eventtimer.test.js deleted file mode 100644 index 662dd2912..000000000 --- a/server/src/classes/timer/__tests__/eventtimer.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { EventTimer } from '../EventTimer'; -import jest from 'jest-mock'; - -// necessary config -const timerConfig = { refresh: 1000 }; - -const mockSocket = { - error: jest.fn(), - send: jest.fn(), - info: jest.fn(), -}; - -test('object instantiates correctly', async () => { - const t = new EventTimer(mockSocket, timerConfig); - - // it contains everything from Timer - expect(t.clock).toBeNull(); - expect(t.duration).toBeNull(); - expect(t.current).toBeNull(); - expect(t.timeTag).toBeNull(); - expect(t.secondaryTimer).toBeNull(); - expect(t._secondaryTarget).toBeNull(); - expect(t._finishAt).toBeNull(); - expect(t._finishedAt).toBeNull(); - expect(t._finishedFlag).toBeFalsy(); - expect(t._startedAt).toBeNull(); - expect(t._pausedAt).toBeNull(); - expect(t._pausedInterval).toBeNull(); - expect(t._pausedTotal).toBeNull(); - expect(t.state).toBe('stop'); - - // and its own properties - expect(t.ontimeCycle).toBe('idle'); - expect(t.prevCycle).toBeNull(); - expect(t.io).not.toBeNull(); - expect(t.osc).toBeNull(); - expect(t.http).toBeNull(); - expect(t._interval).not.toBeNull(); - - const expectTitlesPublic = { - titleNow: null, - subtitleNow: null, - presenterNow: null, - titleNext: null, - subtitleNext: null, - presenterNext: null, - }; - - const expectTitles = { - ...expectTitlesPublic, - noteNow: null, - noteNext: null, - }; - - expect(t.titlesPublic).toStrictEqual(expectTitlesPublic); - expect(t.titles).toStrictEqual(expectTitles); - - expect(t.selectedEventIndex).toBeNull(); - expect(t.selectedEventId).toBeNull(); - expect(t.nextEventId).toBeNull(); - expect(t.selectedPublicEventId).toBeNull(); - expect(t.nextPublicEventId).toBeNull(); - - t.shutdown(); -}); diff --git a/server/src/classes/timer/__tests__/timer.test.js b/server/src/classes/timer/__tests__/timer.test.js deleted file mode 100644 index 7ff7ade44..000000000 --- a/server/src/classes/timer/__tests__/timer.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Timer } from '../Timer'; - -test('object instantiates correctly', () => { - const t = new Timer(); - - expect(t.clock).toBeNull; - expect(t.duration).toBeNull; - expect(t.current).toBeNull; - expect(t.timeTag).toBeNull; - expect(t.secondaryTimer).toBeNull; - expect(t._secondaryTarget).toBeNull; - expect(t._finishAt).toBeNull; - expect(t._finishedAt).toBeNull; - expect(t._finishedFlag).toBeFalsy; - expect(t._startedAt).toBeNull; - expect(t._pausedAt).toBeNull; - expect(t._pausedInterval).toBeNull; - expect(t._pausedTotal).toBeNull; - expect(t.state).toBe('stop'); -}); - -test('convert between mills and seconds correctly', () => { - expect(Timer.toSeconds(10000)).toBe(10); - expect(Timer.toSeconds(9016)).toBe(9); - expect(Timer.toSeconds(8016)).toBe(8); - expect(Timer.toSeconds(7010)).toBe(7); - expect(Timer.toSeconds(6006)).toBe(6); - expect(Timer.toSeconds(4999)).toBe(4); - expect(Timer.toSeconds(2995)).toBe(2); - expect(Timer.toSeconds(1991)).toBe(1); - expect(Timer.toSeconds(992)).toBe(0); - expect(Timer.toSeconds(127)).toBe(0); - expect(Timer.toSeconds(0)).toBe(0); - expect(Timer.toSeconds(-0)).toBe(-0); - expect(Timer.toSeconds(-127)).toBe(-0); - expect(Timer.toSeconds(-992)).toBe(-0); - expect(Timer.toSeconds(-1991)).toBe(-1); - expect(Timer.toSeconds(-2995)).toBe(-2); - expect(Timer.toSeconds(-4999)).toBe(-4); - expect(Timer.toSeconds(-6006)).toBe(-6); - expect(Timer.toSeconds(-7010)).toBe(-7); - expect(Timer.toSeconds(-8016)).toBe(-8); - expect(Timer.toSeconds(-10000)).toBe(-10); -}); - -test('converting between millis to seconds handles partials correctly', () => { - const finish = 82162001; - const now = 80364519; - const runningMs = finish - now; - expect(Timer.toSeconds(runningMs)).toBe(1797); - - expect(Timer.toSeconds(1800000)).toBe(1800); - expect(Timer.toSeconds(1799761)).toBe(1799); -}); diff --git a/server/src/controllers/OscController.js b/server/src/controllers/OscController.js index fb3088638..15b845df4 100644 --- a/server/src/controllers/OscController.js +++ b/server/src/controllers/OscController.js @@ -1,5 +1,5 @@ import { Server } from 'node-osc'; -import { PlaybackService } from '../services/playbackService.js'; +import { PlaybackService } from '../services/PlaybackService.js'; import { messageManager } from '../classes/message-manager/MessageManager.js'; import { socketProvider } from '../classes/socket/SocketController.js'; import { ADDRESS_MESSAGE_CONTROL } from '../classes/socket/socketConfig.js'; @@ -22,7 +22,7 @@ export const initiateOSC = (config) => { oscServer.on('error', console.error); - oscServer.on('message', function(msg) { + oscServer.on('message', function (msg) { // message should look like /ontime/{path} {args} where // ontime: fixed message for app // path: command to be called diff --git a/server/src/controllers/ontimeController.js b/server/src/controllers/ontimeController.js index fe68adf17..0fdeefb4c 100644 --- a/server/src/controllers/ontimeController.js +++ b/server/src/controllers/ontimeController.js @@ -6,7 +6,7 @@ import { resolveDbPath } from '../modules/loadDb.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; import { mergeObject } from '../utils/parserUtils.js'; -import { PlaybackService } from '../services/playbackService.js'; +import { PlaybackService } from '../services/PlaybackService.js'; import { runtimeState } from '../stores/EventStore.js'; // Create controller for GET request to '/ontime/poll' diff --git a/server/src/controllers/playbackController.js b/server/src/controllers/playbackController.js index 8b288c978..e9f76e68e 100644 --- a/server/src/controllers/playbackController.js +++ b/server/src/controllers/playbackController.js @@ -1,6 +1,6 @@ // Create controller for GET request to '/playback' // Returns ACK message -import { PlaybackService } from '../services/playbackService.js'; +import { PlaybackService } from '../services/PlaybackService.js'; // Create controller for POST request to '/playback' // Returns playback state diff --git a/server/src/controllers/rundownController.js b/server/src/controllers/rundownController.js index 452aa2246..1877bba6b 100644 --- a/server/src/controllers/rundownController.js +++ b/server/src/controllers/rundownController.js @@ -7,7 +7,7 @@ import { deleteEvent, editEvent, reorderEvent, -} from '../services/rundownService.js'; +} from '../services/RundownService.js'; // Create controller for GET request to '/eventlist' // Returns - diff --git a/server/src/modules/loadDb.js b/server/src/modules/loadDb.js index ee17bcc4a..6023da5f6 100644 --- a/server/src/modules/loadDb.js +++ b/server/src/modules/loadDb.js @@ -1,4 +1,5 @@ -import { JSONFile, Low } from 'lowdb'; +import { Low } from 'lowdb'; +import { JSONFile } from 'lowdb/node'; import { dirname, join } from 'path'; import { copyFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; diff --git a/server/src/package.json b/server/src/package.json index 29c9bb862..22f3666eb 100644 --- a/server/src/package.json +++ b/server/src/package.json @@ -10,7 +10,7 @@ "express": "^4.18.1", "express-session": "^1.17.3", "express-validator": "^6.14.2", - "lowdb": "3.0.0", + "lowdb": "^5.0.5", "multer": "^1.4.4", "nanoid": "^4.0.0", "node-osc": "^8.0.6", diff --git a/server/src/services/playbackService.js b/server/src/services/PlaybackService.js similarity index 81% rename from server/src/services/playbackService.js rename to server/src/services/PlaybackService.js index 6b9907477..faeb105cd 100644 --- a/server/src/services/playbackService.js +++ b/server/src/services/PlaybackService.js @@ -3,7 +3,7 @@ */ import { socketProvider } from '../classes/socket/SocketController.js'; import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js'; -import { eventTimer } from './TimerService.js'; +import { eventTimer, TimerService } from './TimerService.js'; /** * Service manages playback status of app @@ -121,7 +121,7 @@ export class PlaybackService { if (eventLoader.selectedEventId) { eventTimer.start(); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState}`); + socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } socketProvider.broadcastState(); } @@ -133,7 +133,7 @@ export class PlaybackService { if (eventLoader.selectedEventId) { eventTimer.pause(); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState}`); + socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } socketProvider.broadcastState(); } @@ -146,7 +146,7 @@ export class PlaybackService { eventLoader.reset(); eventTimer.stop(); const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState}`); + socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); } socketProvider.broadcastState(); } @@ -165,13 +165,29 @@ export class PlaybackService { * Sets playback to roll */ static roll() { - if (EventLoader.getNumEvents() && eventTimer.playback !== 'roll') { - eventTimer.roll(); + if (EventLoader.getPlayableEvents()) { + const rollTimers = eventLoader.findRoll(TimerService.getCurrentTime()); + + // nothing to play + if (rollTimers === null) { + socketProvider.error('SERVER', 'Roll: no events found'); + PlaybackService.stop(); + return; + } + + const { currentEvent, nextEvent, timers } = rollTimers; + if (!currentEvent && !nextEvent) { + socketProvider.error('SERVER', 'Roll: no events found'); + PlaybackService.stop(); + return; + } + + eventTimer.roll(currentEvent, nextEvent, timers); + const newState = eventTimer.playback; - socketProvider.info('PLAYBACK', `Play Mode ${newState}`); - socketProvider.send('playback', newState); + socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`); + socketProvider.broadcastState(); } - socketProvider.broadcastState(); } /** diff --git a/server/src/services/rundownService.js b/server/src/services/RundownService.js similarity index 96% rename from server/src/services/rundownService.js rename to server/src/services/RundownService.js index facc57a51..696eaa0be 100644 --- a/server/src/services/rundownService.js +++ b/server/src/services/RundownService.js @@ -86,16 +86,15 @@ export function updateTimer(affectedIds) { if (safeOption) { eventLoader.reset(); - const loadedEvent = eventLoader.loadById(runningEventId); + const { loadedEvent } = eventLoader.loadById(runningEventId) || {}; eventTimer.hotReload(loadedEvent); return true; } if (eventInMemory) { - const loadedEvent = eventLoader.loadById(runningEventId); + eventLoader.reset(); + const { loadedEvent } = eventLoader.loadById(runningEventId) || {}; if (!loadedEvent) { - // event was deleted - eventLoader.reset(); eventTimer.stop(); } else { eventTimer.hotReload(loadedEvent); @@ -104,7 +103,7 @@ export function updateTimer(affectedIds) { } if (isNext) { - const loadedEvent = eventLoader.loadById(runningEventId); + const { loadedEvent } = eventLoader.loadById(runningEventId) || {}; eventTimer.hotReload(loadedEvent); return true; } diff --git a/server/src/services/TimerService.js b/server/src/services/TimerService.js index cf84921b1..2c452aca4 100644 --- a/server/src/services/TimerService.js +++ b/server/src/services/TimerService.js @@ -1,6 +1,9 @@ import { runtimeState } from '../stores/EventStore.js'; +import { PlaybackService } from './PlaybackService.js'; +import { updateRoll } from './rollUtils.js'; +import { DAY_TO_MS } from '../utils/time.js'; -class TimerService { +export class TimerService { /** * @constructor * @param {object} [timerConfig] @@ -62,10 +65,10 @@ class TimerService { finishedAt: null, secondaryTimer: null, }; - this.loadedTimer = null; this.loadedTimerId = null; this._pausedInterval = 0; this._pausedAt = null; + this._secondaryTarget = null; } /** @@ -73,16 +76,28 @@ class TimerService { * @param timer */ hotReload(timer) { - if (timer?.id !== this.loadedTimerId) { + if (typeof timer === 'undefined') { + this.stop(); return; } + + if (timer?.id !== this.loadedTimerId) { + // event timer only concerns itself with current event + return; + } + if (timer?.skip) { this.stop(); } + // TODO: check if any relevant information warrants update + // update relevant information and force update - this.loadedTimer = timer; this.timer.duration = timer.duration; + + // this might not be ideal + this.timer.finishedAt = null; + this.timer.expectedFinish = this._getExpectedFinish(); if (this.timer.startedAt === null) { this.timer.current = timer.duration; } @@ -106,7 +121,6 @@ class TimerService { this._clear(); - this.loadedTimer = timer; this.loadedTimerId = timer.id; this.timer.duration = timer.duration; this.timer.current = timer.duration; @@ -221,27 +235,58 @@ class TimerService { update() { this.timer.clock = TimerService.getCurrentTime(); - // we only update timer if a timer has been started - if (this.timer.startedAt !== null) { - if (this.playback === 'pause') { - this._pausedInterval = this.timer.clock - this._pausedAt; - } + if (this.playback === 'roll') { + const tempCurrentTimer = { + selectedEventId: this.loadedTimerId, + current: this.timer.current, + // safeguard on midnight rollover + _finishAt: + this.timer.expectedFinish >= this.timer.startedAt + ? this.timer.expectedFinish + : this.timer.expectedFinish + DAY_TO_MS, - this.timer.current = - this.timer.startedAt + - this.timer.duration + - this.timer.addedTime + - this._pausedInterval - - this.timer.clock; - this.timer.elapsed = this.timer.duration - this.timer.current; + clock: this.timer.clock, + secondaryTimer: this.timer.secondaryTimer, + _secondaryTarget: this._secondaryTarget, + }; + const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = + updateRoll(tempCurrentTimer); - if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) { - this.timer.finishedAt = this.timer.clock; + this.timer.current = updatedTimer; + this.timer.secondaryTimer = updatedSecondaryTimer; + + if (isFinished) { + this.timer.selectedEventId = null; + this.loadedTimerId = null; this._onFinish(); - } else { - this.timer.finishedAt = null; } - this.timer.expectedFinish = this._getExpectedFinish(); + + if (doRollLoad) { + PlaybackService.roll(); + } + } else { + // we only update timer if a timer has been started + if (this.timer.startedAt !== null) { + if (this.playback === 'pause') { + this._pausedInterval = this.timer.clock - this._pausedAt; + } + + this.timer.current = + this.timer.startedAt + + this.timer.duration + + this.timer.addedTime + + this._pausedInterval - + this.timer.clock; + this.timer.elapsed = this.timer.duration - this.timer.current; + + if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) { + this.timer.finishedAt = this.timer.clock; + this._onFinish(); + } else { + this.timer.finishedAt = null; + } + this.timer.expectedFinish = this._getExpectedFinish(); + } } this._onUpdate(); } @@ -256,12 +301,33 @@ class TimerService { runtimeState.set('ontime-timer', this.timer); } - roll() { + roll(currentEvent, nextEvent, timers) { + this._clear(); + this.timer.clock = TimerService.getCurrentTime(); + + if (currentEvent) { + // there is something running, load + this.timer.secondaryTimer = null; + this._secondaryTarget = null; + + this.loadedTimerId = currentEvent.id; + this.timer.startedAt = currentEvent.timeStart; + this.timer.expectedFinish = currentEvent.timeEnd; + this.timer.duration = timers.duration; + this.timer.current = timers.current; + } else if (nextEvent) { + // nothing now, but something coming up + this.timer.secondaryTimer = nextEvent.timeStart - this.timer.clock; + this._secondaryTarget = nextEvent.timeStart; + } + + this.playback = 'roll'; this._onRoll(); + this.update(); } _onRoll() { - throw new Error('Roll not implemented'); + this._onLoad(); } shutdown() { diff --git a/server/src/classes/timer/__tests__/classUtils.test.js b/server/src/services/__tests__/rollUtils.test.js similarity index 90% rename from server/src/classes/timer/__tests__/classUtils.test.js rename to server/src/services/__tests__/rollUtils.test.js index 3cec2f6c8..00e64c816 100644 --- a/server/src/classes/timer/__tests__/classUtils.test.js +++ b/server/src/services/__tests__/rollUtils.test.js @@ -1,11 +1,11 @@ import { DAY_TO_MS, - getSelectionByRoll, + getRollTimers, normaliseEndTime, replacePlaceholder, sortArrayByProperty, updateRoll, -} from '../classUtils.js'; +} from '../rollUtils.js'; // test sortArrayByProperty() describe('sort simple arrays of objects', () => { @@ -54,7 +54,7 @@ describe('sort simple arrays of objects', () => { }); }); -// test getSelectionByRoll() +// test getRollTimers() describe('test that roll loads selection in right order', () => { const eventlist = [ { @@ -119,7 +119,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: 5, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -140,7 +140,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: 5, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -161,7 +161,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: 5, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -182,7 +182,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: 10, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -203,7 +203,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: 1, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -224,7 +224,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: 7, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -245,7 +245,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: null, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -261,7 +261,7 @@ describe('test that roll loads selection in right order', () => { timeToNext: DAY_TO_MS - now + eventlist[0].timeStart, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -284,12 +284,12 @@ describe('test that roll loads selection in right order', () => { timers: null, timeToNext: DAY_TO_MS - now + singleEventList[0].timeStart, }; - const state = getSelectionByRoll(singleEventList, now); + const state = getRollTimers(singleEventList, now); expect(state).toStrictEqual(expected); }); }); -// test getSelectionByRoll() +// test getRollTimers() describe('test that roll behaviour with overlapping times', () => { const eventlist = [ { @@ -324,7 +324,7 @@ describe('test that roll behaviour with overlapping times', () => { timeToNext: 10, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -345,7 +345,7 @@ describe('test that roll behaviour with overlapping times', () => { timeToNext: 0, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -366,7 +366,7 @@ describe('test that roll behaviour with overlapping times', () => { timeToNext: -5, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -387,7 +387,7 @@ describe('test that roll behaviour with overlapping times', () => { timeToNext: null, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -408,7 +408,7 @@ describe('test that roll behaviour with overlapping times', () => { timeToNext: null, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); }); @@ -475,7 +475,7 @@ describe('test that it replaces data correctly', () => { }); }); -// test getSelectionByRoll() on issue #58 +// 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 @@ -502,7 +502,7 @@ describe('test that roll behaviour multi day event edge cases', () => { timeToNext: null, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); @@ -526,7 +526,7 @@ describe('test that roll behaviour multi day event edge cases', () => { timeToNext: eventlist[0].timeStart - now, }; - const state = getSelectionByRoll(eventlist, now); + const state = getRollTimers(eventlist, now); expect(state).toStrictEqual(expected); }); }); @@ -646,4 +646,24 @@ describe('typical scenarios', () => { 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); + }); }); diff --git a/server/src/classes/timer/classUtils.js b/server/src/services/rollUtils.js similarity index 70% rename from server/src/classes/timer/classUtils.js rename to server/src/services/rollUtils.js index ee9c0d47c..b1f15816c 100644 --- a/server/src/classes/timer/classUtils.js +++ b/server/src/services/rollUtils.js @@ -40,56 +40,43 @@ export const replacePlaceholder = (str, values) => { }; /** - * @description Used in roll mode, returns selection variables from array - * @param {array} arr - event list - * @param {number} now - time now in millis - * @returns {object} object with selection variables + * + * @param rundown + * @param timeNow + * @returns {{}} */ - -export const getSelectionByRoll = (arr, now) => { - // Events now +export const getRollTimers = (rundown, timeNow) => { let nowIndex = null; // index of event now let nowId = null; // id of event now let publicIndex = null; // index of public event now - let publicTime = -1; // counter: - - // Events next + let publicTime = -1; let nextIndex = null; // index of next event let publicNextIndex = null; // index of next public event let timeToNext = null; // counter: time for next event let publicTimeToNext = null; // counter: time for next public event - - // current timer let timers = null; - // exit early if there are no events - if (arr.length < 1) { - return { - nowIndex, - nowId, - publicIndex, - nextIndex, - publicNextIndex, - timers, - timeToNext, - }; - } - // Order events by startTime - const orderedEvents = sortArrayByProperty(arr, 'timeStart'); + const orderedEvents = sortArrayByProperty(rundown, 'timeStart'); // preload first if we are past events const lastEvent = orderedEvents[orderedEvents.length - 1]; const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd); - if (now > lastNormalEnd) { + let nextEvent = null; + let nextPublicEvent = null; + let currentEvent = null; + let currentPublicEvent = null; + + if (timeNow > lastNormalEnd) { nextIndex = 0; - timeToNext = orderedEvents[0].timeStart + DAY_TO_MS - now; + timeToNext = orderedEvents[0].timeStart + DAY_TO_MS - timeNow; // look for next public - for (const e of orderedEvents) { - if (e.isPublic) { - publicNextIndex = arr.findIndex((a) => a.id === e.id); + for (const event of orderedEvents) { + if (event.isPublic) { + nextPublicEvent = event; + publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); break; } } @@ -98,39 +85,42 @@ export const getSelectionByRoll = (arr, now) => { let nowFound = false; // loop through events, look for where we should be - for (const e of orderedEvents) { + for (const event of orderedEvents) { // When does the event end (handle midnight) - const normalEnd = normaliseEndTime(e.timeStart, e.timeEnd); + const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd); - if (normalEnd <= now) { + if (normalEnd <= timeNow) { // event ran already // public event might not be the one running - if (e.isPublic && normalEnd > publicTime) { + if (event.isPublic && normalEnd > publicTime) { publicTime = normalEnd; - publicIndex = arr.findIndex((a) => a.id === e.id); + currentPublicEvent = event; + publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); } - } else if (normalEnd > now && now >= e.timeStart && !nowFound) { + } else if (normalEnd > timeNow && timeNow >= event.timeStart && !nowFound) { // event is running // it could also be public - if (e.isPublic) { + if (event.isPublic) { publicTime = normalEnd; - publicIndex = arr.findIndex((a) => a.id === e.id); + currentPublicEvent = event; + publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); } - nowIndex = arr.findIndex((a) => a.id === e.id); - nowId = e.id; + currentEvent = event; + nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); + nowId = event.id; // set timers timers = { - _startedAt: e.timeStart, - _finishAt: e.timeEnd, - duration: normalEnd - e.timeStart, - current: normalEnd - now, + _startedAt: event.timeStart, + _finishAt: event.timeEnd, + duration: normalEnd - event.timeStart, + current: normalEnd - timeNow, }; nowFound = true; - } else if (normalEnd > now) { + } else if (normalEnd > timeNow) { // event will run // no need to look after found first @@ -138,15 +128,17 @@ export const getSelectionByRoll = (arr, now) => { // look for next events // check how far the start is from now - const wait = e.timeStart - now; + const wait = event.timeStart - timeNow; if (nextIndex === null || wait < timeToNext) { timeToNext = wait; - nextIndex = arr.findIndex((a) => a.id === e.id); + nextEvent = event; + nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); } - if ((publicNextIndex === null || wait < publicTimeToNext) && e.isPublic) { + if ((publicNextIndex === null || wait < publicTimeToNext) && event.isPublic) { publicTimeToNext = wait; - publicNextIndex = arr.findIndex((a) => a.id === e.id); + nextPublicEvent = event; + publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id); } } } @@ -160,6 +152,10 @@ export const getSelectionByRoll = (arr, now) => { publicNextIndex, timers, timeToNext, + nextEvent, + nextPublicEvent, + currentEvent, + currentPublicEvent, }; }; @@ -183,7 +179,7 @@ export const updateRoll = (currentTimers) => { let updatedSecondaryTimer = secondaryTimer; // whether rollLoad should be called let doRollLoad = false; - // whether runCycle should be called + // whether finished event should trigger let isFinished = false; if (selectedEventId && current >= 0) { @@ -194,6 +190,7 @@ export const updateRoll = (currentTimers) => { updatedTimer = _finishAt - clock; if (updatedTimer < 0) { isFinished = true; + updatedTimer = null; } } else if (secondaryTimer >= 0) { // if secondaryTimer is running we are in waiting to roll diff --git a/server/src/utils/getRandomName.js b/server/src/utils/getRandomName.js index 48506a763..c7950878f 100644 --- a/server/src/utils/getRandomName.js +++ b/server/src/utils/getRandomName.js @@ -327,8 +327,6 @@ const adjective = [ 'far-flung', 'far-off', 'fast', - 'fat', - 'fatal', 'fatherly', 'favorable', 'favorite', @@ -1647,7 +1645,6 @@ const object = [ 'hall', 'historian', 'hospital', - 'injury', 'instruction', 'maintenance', 'manufacturer', diff --git a/server/src/utils/time.js b/server/src/utils/time.js index 0ab0e9282..e4cedadd3 100644 --- a/server/src/utils/time.js +++ b/server/src/utils/time.js @@ -4,6 +4,7 @@ const mth = 1000 * 60 * 60; // millis to hours export const timeFormat = 'HH:mm'; export const timeFormatSeconds = 'HH:mm:ss'; +export const DAY_TO_MS = 86400000; /** * @description Validates a time string diff --git a/server/src/yarn.lock b/server/src/yarn.lock index a553882b0..08cb219cd 100644 --- a/server/src/yarn.lock +++ b/server/src/yarn.lock @@ -952,12 +952,12 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -lowdb@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-3.0.0.tgz#c10ab4e7eb86f1cbe255e35e60ffb0c6f42049e0" - integrity sha512-9KZRulmIcU8fZuWiaM0d5e2/nPnrFyXkeXVpqT+MJS+vgbgOf1EbtvgQmba8HwUFgDl1oeZR6XqEJnkJmQdKmg== +lowdb@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-5.0.5.tgz#30315e5a42432df188dcd17f1e5a288de68848e8" + integrity sha512-7EWKmIMhNKA8TXFhL8t0p6N2LC53l3ZqsWQGSksGhhjrcms9rbKlyrAh2PzSGK5v0KPJ2W5VItBnC3NDRzOnzQ== dependencies: - steno "^2.1.0" + steno "^3.0.0" lru_map@^0.3.3: version "0.3.3" @@ -1453,10 +1453,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -steno@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/steno/-/steno-2.1.0.tgz#05a9c378ce42ed04f642cda6fcb41787a10e4e33" - integrity sha512-mauOsiaqTNGFkWqIfwcm3y/fq+qKKaIWf1vf3ocOuTdco9XoHCO2AGF1gFYXuZFSWuP38Q8LBHBGJv2KnJSXyA== +steno@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/steno/-/steno-3.0.0.tgz#212a11e8ef3646b610efc8953842f556fd0df28f" + integrity sha512-uZtn7Ht9yXLiYgOsmo8btj4+f7VxyYheMt8g6F1ANjyqByQXEE2Gygjgenp3otHH1TlHsS4JAaRGv5wJ1wvMNw== streamsearch@0.1.2: version "0.1.2"