refactor: roll mode

* chore: escalate stack trace to console

* refactor: event finding in roll

* docs: initial roll specification

* refactor: call to roll

* refactor: normalise index

* refactor: roll into next event

* refactor: hot reload on waiting to roll

* refactor: wait to roll next
This commit is contained in:
Carlos Valente
2024-08-04 21:34:01 +02:00
committed by GitHub
parent 3012d73285
commit da11ef64c2
21 changed files with 1093 additions and 982 deletions
+7 -1
View File
@@ -19,7 +19,7 @@ import {
resolvePublicDirectoy, resolvePublicDirectoy,
} from './setup/index.js'; } from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js'; import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
// Import Routers // Import Routers
import { appRouter } from './api-data/index.js'; import { appRouter } from './api-data/index.js';
@@ -285,12 +285,18 @@ export const shutdown = async (exitCode = 0) => {
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`)); process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => { process.on('unhandledRejection', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error); generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`); logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1); await shutdown(1);
}); });
process.on('uncaughtException', async (error) => { process.on('uncaughtException', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error); generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`); logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1); await shutdown(1);
@@ -9,7 +9,7 @@ type UpdateCallbackFn = (updateResult: UpdateResult) => void;
/** /**
* Service manages Ontime's main timer * Service manages Ontime's main timer
*/ */
export class TimerService { export class EventTimer {
private readonly _interval: NodeJS.Timeout; private readonly _interval: NodeJS.Timeout;
/** how often we recalculate */ /** how often we recalculate */
static _refreshInterval: number; static _refreshInterval: number;
@@ -26,15 +26,14 @@ export class TimerService {
* @param {function} [timerConfig.onUpdateCallback] how often we update the socket * @param {function} [timerConfig.onUpdateCallback] how often we update the socket
*/ */
constructor(timerConfig: { refresh: number; updateInterval: number }) { constructor(timerConfig: { refresh: number; updateInterval: number }) {
TimerService._refreshInterval = timerConfig.refresh; EventTimer._refreshInterval = timerConfig.refresh;
this._interval = setInterval(() => { this._interval = setInterval(() => {
this.update(); this.update();
}, TimerService._refreshInterval); }, EventTimer._refreshInterval);
} }
/** /**
* Allows setting a callback for when the timer updates * Allows setting a callback for when the timer updates
* @param callback
*/ */
setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) { setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) {
this.onUpdateCallback = callback; this.onUpdateCallback = callback;
@@ -73,7 +72,6 @@ export class TimerService {
/** /**
* Adds time to running timer by given amount * Adds time to running timer by given amount
* @param {number} amount
*/ */
addTime(amount: number): boolean { addTime(amount: number): boolean {
if (!runtimeState.addTime(amount)) { if (!runtimeState.addTime(amount)) {
@@ -104,10 +102,9 @@ export class TimerService {
/** /**
* Loads roll information into timer service * Loads roll information into timer service
* @param {OntimeEvent[]} rundown -- list of events to run
*/ */
roll(rundown: OntimeRundown) { roll(rundown: OntimeRundown) {
runtimeState.roll(rundown); return runtimeState.roll(rundown);
} }
shutdown() { shutdown() {
@@ -0,0 +1,448 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { loadRoll } from '../rollUtils.js';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
};
function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
describe('loadRoll()', () => {
const eventlist = [
{
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,
},
];
const timedEvents = prepareTimedEvents(eventlist);
it('should roll to the day after if timer is at 100', () => {
const now = 100;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should be waiting to start if timer is at 0', () => {
const now = 0;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the first event if timer is at 5', () => {
const now = 5;
const expected = {
event: timedEvents[0],
index: 0,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the second event if timer is at 15', () => {
const now = 15;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the third event if timer is at 10', () => {
const now = 20;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the fifth event if timer is at 49', () => {
const now = 49;
const expected = {
event: timedEvents[4],
index: 4,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the seventh event if timer is at 63', () => {
const now = 63;
const expected = {
event: timedEvents[6],
index: 6,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should start the eight event if timer is at 75', () => {
const now = 75;
const expected = {
event: timedEvents[7],
index: 7,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() handle edge cases with midnight', () => {
it('should find an event that crosses midnight', () => {
const now = 23 * MILLIS_PER_HOUR;
const eventlist = [
{
id: '0',
timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '1',
timeStart: 20 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '2',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
isPublic: true,
},
{
id: '3',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
isPublic: true,
},
{
id: '4',
timeStart: 1 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
},
];
const timedEvents = prepareTimedEvents(eventlist);
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('should not skip to the second day', () => {
/**
* NOTE: this is a potentially contentious decision
*
* The idea here is that it makes no sense for us to jump to the second / third day on activating roll
* if the user wants to skip a portion of the rundown, they can manually jump to the event and activate roll
*
* On our side, this simplifies logic and makes behaviour more predictable
*/
const now = 8 * MILLIS_PER_HOUR;
const eventlist = [
{
id: '0',
timeStart: 21 * MILLIS_PER_HOUR,
timeEnd: 22 * MILLIS_PER_HOUR,
},
{
id: '1',
timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 3 * MILLIS_PER_HOUR,
},
{
id: '2',
timeStart: 3 * MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR,
},
];
const timedEvents = prepareTimedEvents(eventlist);
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() handle edge cases with before and after start', () => {
it('should prepare first event, if we are not yet in the rundown start', () => {
const now = 7 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
it('should prepare first event, if we are over the rundown end', () => {
const now = 18 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
it('should account for a rundown that goes through midnight', () => {
const now = 1 * MILLIS_PER_HOUR;
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
};
const state = loadRoll(singleEventList, now);
expect(state.isPending).toBeUndefined();
expect(state).toStrictEqual(expected);
});
it('loads upcoming event while waiting to roll', () => {
const now = 6000; // 00:01
const singleEventList = [
makeOntimeEvent({
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
}),
];
const expected = {
event: singleEventList[0],
index: 0,
isPending: true,
};
const state = loadRoll(singleEventList, now);
expect(state).toStrictEqual(expected);
});
});
describe('loadRoll() test that roll behaviour with overlapping times', () => {
const eventlist = [
{
id: '1',
timeStart: 10,
timeEnd: 10,
isPublic: false,
},
{
id: '2',
timeStart: 10,
timeEnd: 20,
isPublic: true,
},
{
id: '3',
timeStart: 10,
timeEnd: 30,
isPublic: false,
},
];
const timedEvents = prepareTimedEvents(eventlist);
it('if timer is at 0', () => {
const now = 0;
const expected = {
event: timedEvents[0],
index: 0,
isPending: true,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 10, it ignores events with 0 duration', () => {
const now = 10;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 15', () => {
const now = 15;
const expected = {
event: timedEvents[1],
index: 1,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 20', () => {
const now = 20;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
it('if timer is at 25', () => {
const now = 25;
const expected = {
event: timedEvents[2],
index: 2,
};
const state = loadRoll(timedEvents, now);
expect(state).toStrictEqual(expected);
});
});
// issue #58
describe('loadRoll() test that roll behaviour multi day event edge cases', () => {
it('should recognise a playing event where its schedule spans over midnight', () => {
const now = 66600000; // 19:30
const eventlist = [
makeOntimeEvent({
id: '1',
timeStart: 66000000, // 19:20
timeEnd: 54600000, // 16:10
isPublic: false,
}),
];
const expected = {
event: eventlist[0],
index: 0,
};
const state = loadRoll(eventlist, 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 = [
makeOntimeEvent({
id: '1',
timeStart: 67200000, // 19:40
timeEnd: 66900000, // 19:35
isPublic: false,
}),
];
const expected = {
event: eventlist[0],
index: 0,
};
const state = loadRoll(eventlist, now);
expect(state).toStrictEqual(expected);
});
});
@@ -1,10 +1,9 @@
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils'; import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { import {
getCurrent, getCurrent,
getExpectedFinish, getExpectedFinish,
getRollTimers,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
getTotalDuration, getTotalDuration,
@@ -696,520 +695,6 @@ describe('skippedOutOfEvent()', () => {
}); });
}); });
describe('getRollTimers()', () => {
const eventlist: Partial<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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('loads upcoming event while waiting to roll', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
id: '1',
timeStart: 72000000, // 20:00
timeEnd: 72010000, // 20:10
isPublic: true,
},
];
const now = 6000; // 00:01
const expected = {
nowIndex: null,
nowId: null,
publicIndex: null,
nextIndex: 0,
publicNextIndex: 0,
timeToNext: 72000000 - now,
nextEvent: singleEventList[0],
nextPublicEvent: singleEventList[0],
currentEvent: null,
currentPublicEvent: null,
};
const state = getRollTimers(singleEventList as OntimeEvent[], now);
expect(state).toStrictEqual(expected);
});
it('handles roll that goes over midnight', () => {
const singleEventList: Partial<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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<OntimeEvent>[] = [
{
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()', () => { test('normaliseEndTime()', () => {
const t1 = { const t1 = {
start: 10, start: 10,
+93
View File
@@ -0,0 +1,93 @@
import { dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
import { OntimeEvent, MaybeNumber, PlayableEvent, isPlayableEvent } from 'ontime-types';
import { normaliseEndTime } from './timerUtils.js';
/**
* Finds current event in a rolling rundown
*/
export function loadRoll(
timedEvents: OntimeEvent[],
timeNow: number,
): {
event: PlayableEvent | null;
index: MaybeNumber;
isPending?: boolean;
} {
const { firstEvent } = getFirstEvent(timedEvents);
const { lastEvent } = getLastEvent(timedEvents);
if (!firstEvent || !lastEvent) {
return { event: null, index: null };
}
// check that the rundown wraps around midnight
const wrapsAroundMidnight = firstEvent.timeStart > lastEvent.timeEnd;
if (!wrapsAroundMidnight) {
// check whether we are before or after the rundown
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
const isAfterRundown = timeNow > lastNormalEnd;
const isBeforeRundown = timeNow < firstEvent.timeStart && !isAfterRundown;
if (isAfterRundown || isBeforeRundown) {
return { event: firstEvent, index: 0, isPending: true };
}
}
// we know we are in the middle of the rundown and we need to find the current event
// account for number of times we went over midnight
let daySpan = 0;
for (let i = 0; i < timedEvents.length; i++) {
const event = timedEvents[i];
if (!isPlayableEvent(event)) {
continue;
}
// we check if event crosses midnight
if (event.timeStart > event.timeEnd) {
daySpan++;
}
const correctedDays = dayInMs * daySpan;
const correctedStart = event.timeStart + correctedDays;
const correctedEnd = event.timeEnd + correctedDays;
/**
* there are 3 possible states for an event
* 1. event is already finished
* 2. event is running
* 3. event is in the future
*/
// 1. event is already finished
// when does the event end (handle midnight)
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
if (normalEnd <= timeNow) {
continue;
}
// 2. event is running and is the first event in our time slot
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (hasStarted) {
return { event, index: i };
}
// 3. event will run in the future
// we set the isPending flag to indicate that the event is currently playing
return { event, index: i, isPending: true };
}
// in case we were unable to find anything, we load the first event
return { event: firstEvent, index: 0, isPending: true };
}
/**
* Utility function, checks whether the event start is the day after
*/
export function normaliseRollStart(start: number, clock: number) {
return start < clock ? start + dayInMs : start;
}
@@ -20,7 +20,7 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js'; import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
import { getPlayableEvents } from './rundownUtils.js'; import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string }; type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
@@ -215,8 +215,8 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object * Called when we make changes to the rundown object
*/ */
function updateRuntimeOnChange() { function updateRuntimeOnChange() {
const playableEvents = getPlayableEvents(); const timedEvents = getTimedEvents();
const numEvents = playableEvents.length; const numEvents = timedEvents.length;
const metadata = cache.getMetadata(); const metadata = cache.getMetadata();
// schedule an update for the end of the event loop // schedule an update for the end of the event loop
@@ -1,14 +1,17 @@
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached, OntimeRundownEntry } from 'ontime-types'; import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types';
import { filterPlayable, filterTimedEvents } from 'ontime-utils';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
/**
* returns the normalised rundown
*/
export function getNormalisedRundown(): RundownCached { export function getNormalisedRundown(): RundownCached {
return cache.get(); return cache.get();
} }
/** /**
* returns entire unfiltered rundown * returns entire unfiltered rundown
* @return {array}
*/ */
export function getRundown(): OntimeRundown { export function getRundown(): OntimeRundown {
return cache.getPersistedRundown(); return cache.getPersistedRundown();
@@ -16,32 +19,20 @@ export function getRundown(): OntimeRundown {
/** /**
* returns all events of type OntimeEvent * returns all events of type OntimeEvent
* @return {array}
*/ */
export function getTimedEvents(): OntimeEvent[] { export function getTimedEvents(): OntimeEvent[] {
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[]; return filterTimedEvents(getRundown());
} }
/** /**
* returns all events that can be loaded * returns all events that can be loaded
* @return {array}
*/ */
export function getPlayableEvents(): OntimeEvent[] { export function getPlayableEvents(): PlayableEvent[] {
return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; return filterPlayable(getRundown());
}
/**
* returns number of events that can be loaded
* @return {number}
*/
export function getNumEvents(): number {
return getPlayableEvents().length;
} }
/** /**
* returns an event given its index after filtering for OntimeEvents * returns an event given its index after filtering for OntimeEvents
* @param {number} eventIndex
* @return {OntimeEvent | undefined}
*/ */
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
const timedEvents = getTimedEvents(); const timedEvents = getTimedEvents();
@@ -50,8 +41,6 @@ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
/** /**
* returns first event that matches a given ID * returns first event that matches a given ID
* @param {string} eventId
* @return {object | undefined}
*/ */
export function getEventWithId(eventId: string): OntimeRundownEntry | undefined { export function getEventWithId(eventId: string): OntimeRundownEntry | undefined {
const rundown = getRundown(); const rundown = getRundown();
@@ -60,17 +49,14 @@ export function getEventWithId(eventId: string): OntimeRundownEntry | undefined
/** /**
* returns first event that matches a given cue * returns first event that matches a given cue
* @param {string} targetCue
* @param {number} currentEventIndex
* @return {object | undefined}
*/ */
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined { export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
const timedEvents = getPlayableEvents(); const playableEvents = getPlayableEvents();
const lowerCaseCue = targetCue.toLowerCase(); const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < timedEvents.length; i++) { for (let i = currentEventIndex; i < playableEvents.length; i++) {
const event = timedEvents.at(i); const event = playableEvents.at(i);
if (event && event.cue.toLowerCase() === lowerCaseCue) { if (event?.cue.toLowerCase() === lowerCaseCue) {
return event; return event;
} }
} }
@@ -78,43 +64,41 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O
/** /**
* finds the previous event * finds the previous event
* @return {object | undefined}
*/ */
export function findPrevious(currentEventId?: string): OntimeEvent | null { export function findPrevious(currentEventId?: string): OntimeEvent | null {
const timedEvents = getPlayableEvents(); const playableEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) { if (!playableEvents || !playableEvents.length) {
return null; return null;
} }
// if there is no event running, go to first // if there is no event running, go to first
if (!currentEventId) { if (!currentEventId) {
return timedEvents.at(0) ?? null; return playableEvents.at(0) ?? null;
} }
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const newIndex = Math.max(currentIndex - 1, 0); const newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = timedEvents.at(newIndex) ?? null; const previousEvent = playableEvents.at(newIndex) ?? null;
return previousEvent; return previousEvent;
} }
/** /**
* finds the next event * finds the next event
* @return {object | undefined}
*/ */
export function findNext(currentEventId?: string): OntimeEvent | null { export function findNext(currentEventId?: string): PlayableEvent | null {
const timedEvents = getPlayableEvents(); const playableEvents = getPlayableEvents();
if (!timedEvents || !timedEvents.length) { if (!playableEvents.length) {
return null; return null;
} }
// if there is no event running, go to first // if there is no event running, go to first
if (!currentEventId) { if (!currentEventId) {
return timedEvents.at(0) ?? null; return playableEvents.at(0) ?? null;
} }
const currentIndex = timedEvents.findIndex((event) => event.id === currentEventId); const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId);
const newIndex = currentIndex + 1; const newIndex = currentIndex + 1;
const nextEvent = timedEvents.at(newIndex); const nextEvent = playableEvents.at(newIndex);
return nextEvent ?? null; return nextEvent ?? null;
} }
@@ -1,6 +1,7 @@
import { import {
EndAction, EndAction,
isOntimeEvent, isOntimeEvent,
isPlayableEvent,
LogOrigin, LogOrigin,
MaybeNumber, MaybeNumber,
OntimeEvent, OntimeEvent,
@@ -9,7 +10,7 @@ import {
TimerLifeCycle, TimerLifeCycle,
TimerPhase, TimerPhase,
} from 'ontime-types'; } from 'ontime-types';
import { filterPlayable, millisToString, validatePlayback } from 'ontime-utils'; import { millisToString, validatePlayback } from 'ontime-utils';
import { deepEqual } from 'fast-equals'; import { deepEqual } from 'fast-equals';
@@ -19,7 +20,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import { timerConfig } from '../../config/config.js'; import { timerConfig } from '../../config/config.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
import { TimerService } from '../TimerService.js'; import { EventTimer } from '../EventTimer.js';
import { RestorePoint, restoreService } from '../RestoreService.js'; import { RestorePoint, restoreService } from '../RestoreService.js';
import { import {
findNext, findNext,
@@ -27,20 +28,20 @@ import {
getEventAtIndex, getEventAtIndex,
getNextEventWithCue, getNextEventWithCue,
getEventWithId, getEventWithId,
getPlayableEvents,
getRundown, getRundown,
getTimedEvents,
} from '../rundown-service/rundownUtils.js'; } from '../rundown-service/rundownUtils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js'; import { integrationService } from '../integration-service/IntegrationService.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
/** /**
* Service manages runtime status of app * Service manages runtime status of app
* Coordinating with necessary services * Coordinating with necessary services
*/ */
class RuntimeService { class RuntimeService {
private eventTimer: TimerService; private eventTimer: EventTimer;
private lastIntegrationClockUpdate = -1; private lastIntegrationClockUpdate = -1;
private lastIntegrationTimerValue = -1; private lastIntegrationTimerValue = -1;
@@ -52,8 +53,8 @@ class RuntimeService {
/** last known state */ /** last known state */
static previousState: RuntimeState; static previousState: RuntimeState;
constructor(timerService: TimerService) { constructor(eventTimer: EventTimer) {
this.eventTimer = timerService; this.eventTimer = eventTimer;
RuntimeService.previousTimerUpdate = -1; RuntimeService.previousTimerUpdate = -1;
RuntimeService.previousTimerValue = -1; RuntimeService.previousTimerValue = -1;
@@ -63,7 +64,7 @@ class RuntimeService {
/** Checks result of an update and notifies integrations as needed */ /** Checks result of an update and notifies integrations as needed */
@broadcastResult @broadcastResult
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) { checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) {
const newState = runtimeState.getState(); const newState = runtimeState.getState();
// 1. find if we need to dispatch integrations related to the phase // 1. find if we need to dispatch integrations related to the phase
@@ -82,35 +83,36 @@ class RuntimeService {
// 2. handle edge cases related to roll // 2. handle edge cases related to roll
if (newState.timer.playback === Playback.Roll) { if (newState.timer.playback === Playback.Roll) {
// check if we need to call roll again // check if we need to call any side effects
const needsEvent =
newState.eventNow === null
? true
: skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit);
const hasFinishedRoll = hasTimerFinished && shouldCallRoll;
if (shouldCallRoll || needsEvent) {
if (hasFinishedRoll) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
}
// we dont call this.roll because we need to bypass the checks if (hasSecondaryTimerFinished) {
const rundown = getRundown(); // if the secondary timer has finished, we need to call roll
// TODO: by not calling roll, we dont get the events // since event is already loaded
this.eventTimer.roll(rundown); this.rollLoaded();
} else if (hasTimerFinished) {
// if the timer has finished, we need to load next and keep rolling
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
this.loadNext();
this.rollLoaded();
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
// if we have skipped out of the event, we will recall roll
// to push the playback to the right place
// this comes with the caveat that we will lose our runtime data
this.roll(true);
} }
} }
// 3. find if we need to process actions related to the timer finishing // 3. find if we need to process actions related to the timer finishing
if (newState.timer.playback !== Playback.Roll && hasTimerFinished) { if (newState.timer.playback === Playback.Play && hasTimerFinished) {
process.nextTick(() => { process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish); integrationService.dispatch(TimerLifeCycle.onFinish);
}); });
// handle end action if there was a timer playing // handle end action if there was a timer playing
// actions are added to the queue stack to ensure that the order of operations is maintained // actions are added to the queue stack to ensure that the order of operations is maintained
if (newState.timer.playback === Playback.Play && newState.eventNow) { if (newState.eventNow) {
if (newState.eventNow.endAction === EndAction.Stop) { if (newState.eventNow.endAction === EndAction.Stop) {
setTimeout(this.stop.bind(this), 0); setTimeout(this.stop.bind(this), 0);
} else if (newState.eventNow.endAction === EndAction.LoadNext) { } else if (newState.eventNow.endAction === EndAction.LoadNext) {
@@ -177,7 +179,7 @@ class RuntimeService {
} }
private isNewNext() { private isNewNext() {
const timedEvents = getPlayableEvents(); const timedEvents = getTimedEvents();
const state = runtimeState.getState(); const state = runtimeState.getState();
const now = state.eventNow?.id; const now = state.eventNow?.id;
const next = state.eventNext?.id; const next = state.eventNext?.id;
@@ -233,41 +235,41 @@ class RuntimeService {
// 3. the edited event replaces next event // 3. the edited event replaces next event
let isNext = false; let isNext = false;
// TODO: review logic
if (safeOption || eventInMemory) { if (safeOption || eventInMemory) {
if (state.timer.playback === Playback.Roll) { if (state.eventNow !== null) {
this.roll(); // load stuff again, but keep running if our events still exist
} const eventNow = getEventWithId(state.eventNow.id);
// load stuff again, but keep running if our events still exist if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) {
const eventNow = getEventWithId(state.eventNow.id); return;
if (!isOntimeEvent(eventNow)) { }
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
} else {
const rundown = getRundown();
runtimeState.reloadAll(rundown);
}
return; return;
} }
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
} else {
const rundown = getRundown();
runtimeState.reloadAll(eventNow, rundown);
}
return;
} }
// Maybe the event will become the next // Maybe the event will become the next
isNext = this.isNewNext(); isNext = this.isNewNext();
if (isNext) { if (isNext) {
const rundown = getRundown(); const timedEvents = getTimedEvents();
runtimeState.loadNext(rundown); runtimeState.loadNext(timedEvents);
} }
} }
/** /**
* makes calls for loading and starting given event * makes calls for loading and starting given event
* @param {OntimeEvent} event * @param {PlayableEvent} event
* @return {boolean} success - whether an event was loaded * @return {boolean} success - whether an event was loaded
*/ */
@broadcastResult @broadcastResult
loadEvent(event: OntimeEvent): boolean { loadEvent(event: OntimeEvent): boolean {
if (event.skip) { if (!isPlayableEvent(event)) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false; return false;
} }
@@ -407,14 +409,16 @@ class RuntimeService {
*/ */
@broadcastResult @broadcastResult
start(): boolean { start(): boolean {
const state = runtimeState.getState(); const previousState = runtimeState.getState();
const canStart = validatePlayback(state.timer.playback).start; const canStart = validatePlayback(previousState.timer.playback).start;
if (!canStart) { if (!canStart) {
return false; return false;
} }
const didStart = this.eventTimer?.start() ?? false; const didStart = this.eventTimer?.start() ?? false;
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`); const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
if (didStart) { if (didStart) {
process.nextTick(() => { process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart); integrationService.dispatch(TimerLifeCycle.onStart);
@@ -505,29 +509,56 @@ class RuntimeService {
} }
} }
/**
* Handles special case to call roll on a loaded event which we do not want to discard
*/
rollLoaded() {
const rundown = getRundown();
try {
this.eventTimer.roll(rundown);
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
}
}
/** /**
* Sets playback to roll * Sets playback to roll
*/ */
@broadcastResult @broadcastResult
roll() { roll(skipCheck: boolean = false) {
const beforeState = runtimeState.getState(); const previousState = runtimeState.getState();
const canRoll = validatePlayback(beforeState.timer.playback).roll; if (!skipCheck) {
if (!canRoll) { const canRoll = validatePlayback(previousState.timer.playback).roll;
if (!canRoll) {
return;
}
}
try {
const rundown = getRundown();
const result = this.eventTimer.roll(rundown);
if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onLoad);
});
}
if (result.didStart) {
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart);
});
}
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
return; return;
} }
const rundown = getRundown(); const newState = runtimeState.getState();
const playableEvents = filterPlayable(rundown);
if (playableEvents.length === 0) { if (previousState.timer.playback !== newState.timer.playback) {
logger.warning(LogOrigin.Server, 'Roll: no events found'); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
return;
} }
this.eventTimer.roll(rundown);
const state = runtimeState.getState();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
} }
/** /**
@@ -549,7 +580,7 @@ class RuntimeService {
// the db would have to change for the event not to exist // the db would have to change for the event not to exist
// we do not know the reason for the crash, so we check anyway // we do not know the reason for the crash, so we check anyway
const event = getEventWithId(selectedEventId); const event = getEventWithId(selectedEventId);
if (!event || !isOntimeEvent(event)) { if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return; return;
} }
@@ -570,7 +601,7 @@ class RuntimeService {
} }
// calculate at 30fps, refresh at 1fps // calculate at 30fps, refresh at 1fps
const eventTimer = new TimerService({ const eventTimer = new EventTimer({
refresh: timerConfig.updateRate, refresh: timerConfig.updateRate,
updateInterval: timerConfig.notificationRate, updateInterval: timerConfig.notificationRate,
}); });
+5 -155
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types'; import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js'; import { RuntimeState } from '../stores/runtimeState.js';
@@ -93,12 +93,11 @@ export function getCurrent(state: RuntimeState): number {
* @returns {boolean} * @returns {boolean}
*/ */
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean { export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// eslint-disable-next-line no-unused-labels -- dev code path // we cant have skipped if we havent started
DEV: { if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
if (state.timer.expectedFinish === null || state.timer.startedAt === null) { return false;
throw new Error('timerUtils.skippedOutOfEvent: invalid state received');
}
} }
const { startedAt, expectedFinish } = state.timer; const { startedAt, expectedFinish } = state.timer;
const { clock } = state; const { clock } = state;
@@ -112,155 +111,6 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt); return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
} }
type RollTimers = {
nowIndex: MaybeNumber;
nowId: MaybeString;
publicIndex: MaybeNumber;
nextIndex: MaybeNumber;
publicNextIndex: MaybeNumber;
timeToNext: MaybeNumber;
nextEvent: OntimeEvent | null;
nextPublicEvent: OntimeEvent | null;
currentEvent: OntimeEvent | null;
currentPublicEvent: OntimeEvent | null;
};
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} playableEvents - List of playable events
* @param {number} timeNow - time now in ms
*/
export const getRollTimers = (
playableEvents: OntimeEvent[],
timeNow: number,
currentIndex?: number | null,
): RollTimers => {
let nowIndex: MaybeNumber = null; // index of event now
let nowId: MaybeString = null; // id of event now
let publicIndex: MaybeNumber = null; // index of public event now
let nextIndex: MaybeNumber = null; // index of next event
let publicNextIndex: MaybeNumber = null; // index of next public event
let timeToNext: MaybeNumber = null; // counter: time for next event
let publicTimeToNext: MaybeNumber = null; // counter: time for next public event
const hasLoaded = currentIndex !== null;
const canFilter = hasLoaded && currentIndex === playableEvents.length - 1;
const filteredRundown = canFilter ? playableEvents.slice(currentIndex) : playableEvents;
const lastEvent = filteredRundown.at(-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 = filteredRundown.at(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 filteredRundown) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = filteredRundown.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 filteredRundown) {
// 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 = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = filteredRundown.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 = filteredRundown.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 = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/** /**
* Calculates difference between the runtime and the schedule of an event * Calculates difference between the runtime and the schedule of an event
* Positive offset is time ahead * Positive offset is time ahead
@@ -1,4 +1,4 @@
import { OntimeEvent, Playback } from 'ontime-types'; import { PlayableEvent, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js'; import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
@@ -11,7 +11,8 @@ const mockEvent = {
timeStart: 0, timeStart: 0,
timeEnd: 1000, timeEnd: 1000,
duration: 1000, duration: 1000,
} as OntimeEvent; skip: false,
} as PlayableEvent;
const mockState = { const mockState = {
clock: 666, clock: 666,
@@ -144,6 +145,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event // 5. Stop event
success = stop(); success = stop();
newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.eventNow).toBe(null); expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
+255 -113
View File
@@ -1,14 +1,17 @@
import { import {
CurrentBlockState, CurrentBlockState,
isPlayableEvent,
MaybeNumber, MaybeNumber,
MaybeString,
OntimeEvent, OntimeEvent,
OntimeRundown, OntimeRundown,
PlayableEvent,
Playback, Playback,
Runtime, Runtime,
TimerPhase, TimerPhase,
TimerState, TimerState,
} from 'ontime-types'; } from 'ontime-types';
import { calculateDuration, dayInMs, filterPlayable, getRelevantBlock } from 'ontime-utils'; import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getRelevantBlock } from 'ontime-utils';
import { clock } from '../services/Clock.js'; import { clock } from '../services/Clock.js';
import { RestorePoint } from '../services/RestoreService.js'; import { RestorePoint } from '../services/RestoreService.js';
@@ -16,12 +19,12 @@ import {
getCurrent, getCurrent,
getExpectedEnd, getExpectedEnd,
getExpectedFinish, getExpectedFinish,
getRollTimers,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
isPlaybackActive, isPlaybackActive,
} from '../services/timerUtils.js'; } from '../services/timerUtils.js';
import { timerConfig } from '../config/config.js'; import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
const initialRuntime: Runtime = { const initialRuntime: Runtime = {
selectedEventIndex: null, // changes if rundown changes or we load a new event selectedEventIndex: null, // changes if rundown changes or we load a new event
@@ -38,8 +41,7 @@ const initialTimer: TimerState = {
current: null, // changes on every update current: null, // changes on every update
duration: null, // only changes if event changes duration: null, // only changes if event changes
elapsed: null, // changes on every update elapsed: null, // changes on every update
// TODO: expected finish could account for midnight, we cleanup in the clients expectedFinish: null, // change can only be initiated by user, can roll over midnight
expectedFinish: null, // change can only be initiated by user
finishedAt: null, // can change on update or user action finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user playback: Playback.Stop, // change initiated by user
@@ -49,11 +51,11 @@ const initialTimer: TimerState = {
export type RuntimeState = { export type RuntimeState = {
clock: number; // realtime clock clock: number; // realtime clock
eventNow: OntimeEvent | null; eventNow: PlayableEvent | null;
currentBlock: CurrentBlockState; currentBlock: CurrentBlockState;
publicEventNow: OntimeEvent | null; publicEventNow: PlayableEvent | null;
eventNext: OntimeEvent | null; eventNext: PlayableEvent | null;
publicEventNext: OntimeEvent | null; publicEventNext: PlayableEvent | null;
runtime: Runtime; runtime: Runtime;
timer: TimerState; timer: TimerState;
// private properties of the timer calculations // private properties of the timer calculations
@@ -61,6 +63,7 @@ export type RuntimeState = {
forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time forceFinish: MaybeNumber; // wether we should declare an event as finished, will contain the finish time
totalDelay: number; // this value comes from rundown service totalDelay: number; // this value comes from rundown service
pausedAt: MaybeNumber; pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
}; };
_prevCurrentBlock: CurrentBlockState; _prevCurrentBlock: CurrentBlockState;
}; };
@@ -81,6 +84,7 @@ const runtimeState: RuntimeState = {
forceFinish: null, forceFinish: null,
totalDelay: 0, totalDelay: 0,
pausedAt: null, pausedAt: null,
secondaryTarget: null,
}, },
_prevCurrentBlock: { _prevCurrentBlock: {
block: null, block: null,
@@ -89,7 +93,17 @@ const runtimeState: RuntimeState = {
}; };
export function getState(): Readonly<RuntimeState> { export function getState(): Readonly<RuntimeState> {
return runtimeState; // create a shallow copy of the state
return {
...runtimeState,
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
publicEventNow: runtimeState.publicEventNow ? { ...runtimeState.publicEventNow } : null,
publicEventNext: runtimeState.publicEventNext ? { ...runtimeState.publicEventNext } : null,
runtime: { ...runtimeState.runtime },
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
};
} }
export function clear() { export function clear() {
@@ -129,7 +143,7 @@ function patchTimer(newState: Partial<TimerState>) {
} }
type RundownData = { type RundownData = {
numEvents: number; numEvents: number; // length of rundown filtered for timed events
firstStart: MaybeNumber; firstStart: MaybeNumber;
lastEnd: MaybeNumber; lastEnd: MaybeNumber;
totalDelay: number; totalDelay: number;
@@ -152,30 +166,33 @@ export function updateRundownData(rundownData: RundownData) {
/** /**
* Loads a given event into state * Loads a given event into state
* @param event
* @param {OntimeEvent[]} playableEvents list of events availebe for playback
* @param {OntimeRundown} rundown the full rundown
* @param initialData potential data from restore point
*/ */
export function load( export function load(
event: OntimeEvent, event: PlayableEvent,
rundown: OntimeRundown, rundown: OntimeRundown,
initialData?: Partial<TimerState & RestorePoint>, initialData?: Partial<TimerState & RestorePoint>,
): boolean { ): boolean {
clear(); clear();
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id); // filter rundown
const timedEvents = filterTimedEvents(rundown);
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
runtimeState.runtime.selectedEventIndex = eventIndex; if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) {
return false;
}
loadNow(event, rundown); // load events in memory along with their data
loadNext(rundown); loadNow(timedEvents, eventIndex);
loadNext(timedEvents, eventIndex);
runtimeState.clock = clock.timeNow(); // update state
runtimeState.timer.playback = Playback.Armed; runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd); runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.runtime.numEvents = timedEvents.length;
// patch with potential provided data
if (initialData) { if (initialData) {
patchTimer(initialData); patchTimer(initialData);
@@ -190,11 +207,25 @@ export function load(
return event.id === runtimeState.eventNow?.id; return event.id === runtimeState.eventNow?.id;
} }
export function loadNow(event: OntimeEvent, rundown: OntimeRundown) { /**
runtimeState.eventNow = event; * Loads current event and its public counterpart
runtimeState.currentBlock.block = getRelevantBlock(rundown, event.id); */
export function loadNow(timedEvents: OntimeEvent[], eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex) {
if (eventIndex === null) {
// reset the state to indicate there is no selection
runtimeState.runtime.selectedEventIndex = null;
runtimeState.eventNow = null;
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
return;
}
//if we are still in the same block keep the startedAt time const event = timedEvents[eventIndex] as PlayableEvent;
runtimeState.runtime.selectedEventIndex = eventIndex;
runtimeState.eventNow = event;
runtimeState.currentBlock.block = getRelevantBlock(timedEvents, event.id);
// if we are still in the same block keep the startedAt time
if (runtimeState._prevCurrentBlock.block?.id === runtimeState.currentBlock.block?.id) { if (runtimeState._prevCurrentBlock.block?.id === runtimeState.currentBlock.block?.id) {
runtimeState.currentBlock.startedAt = runtimeState._prevCurrentBlock.startedAt; runtimeState.currentBlock.startedAt = runtimeState._prevCurrentBlock.startedAt;
} }
@@ -207,73 +238,81 @@ export function loadNow(event: OntimeEvent, rundown: OntimeRundown) {
runtimeState.publicEventNow = null; runtimeState.publicEventNow = null;
// if there is nothing before, return // if there is nothing before, return
if (!runtimeState.runtime.selectedEventIndex) { if (!eventIndex) {
return; return;
} }
const playableEvents = filterPlayable(rundown);
// iterate backwards to find it // iterate backwards to find it
for (let i = runtimeState.runtime.selectedEventIndex; i >= 0; i--) { for (let i = eventIndex; i >= 0; i--) {
if (playableEvents[i].isPublic) { const event = timedEvents[i];
runtimeState.publicEventNow = playableEvents[i]; // we dont deal with events that are not playable
if (!isPlayableEvent(event)) {
continue;
}
if (event.isPublic) {
runtimeState.publicEventNow = event;
break; break;
} }
} }
} }
} }
export function loadNext(rundown: OntimeRundown) { /**
// assume there are no next events * Loads the next event and its public counterpart
runtimeState.eventNext = null; */
runtimeState.publicEventNext = null; export function loadNext(
timedEvents: OntimeEvent[],
if (runtimeState.runtime.selectedEventIndex === null) { eventIndex: MaybeNumber = runtimeState.runtime.selectedEventIndex,
) {
if (eventIndex === null) {
// reset the state to indicate there is no future event
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
return; return;
} }
const playableEvents = filterPlayable(rundown); for (let i = eventIndex + 1; i < timedEvents.length; i++) {
const numEvents = playableEvents.length; const event = timedEvents[i];
// we dont deal with events that are not playable
if (!isPlayableEvent(event)) {
continue;
}
if (runtimeState.runtime.selectedEventIndex < numEvents - 1) { // the private event is the one immediately after the current event
let nextPublic = false; if (runtimeState.eventNext === null) {
let nextProduction = false; runtimeState.eventNext = event;
}
for (let i = runtimeState.runtime.selectedEventIndex + 1; i < numEvents; i++) { // if event is public
// if we have not set private if (event.isPublic) {
if (!nextProduction) { runtimeState.publicEventNext = event;
runtimeState.eventNext = playableEvents[i]; }
nextProduction = true;
}
// if event is public // Stop if both are set
if (playableEvents[i].isPublic) { if (runtimeState.eventNext !== null && runtimeState.publicEventNext !== null) {
runtimeState.publicEventNext = playableEvents[i]; return;
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextProduction) break;
} }
} }
} }
/** /**
* Resume from restore point * Resume from restore point
* @param restorePoint
* @param event
* @param playableEvents list of events availebe for playback
* @param rundown the full rundown
*/ */
export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown: OntimeRundown) { export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) {
load(event, rundown, restorePoint); load(event, rundown, restorePoint);
} }
/** /**
* We only pass an event if we are hot reloading * 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 * @param {PlayableEvent} event only passed if we are changing the data if a playing timer
*/ */
export function reload(event?: OntimeEvent) { export function reload(event?: PlayableEvent): string | undefined {
// if there is no event loaded, nothing to do
if (runtimeState.eventNow === null) {
return;
}
// we only pass an event for hot reloading, ie: the event has changed // we only pass an event for hot reloading, ie: the event has changed
if (event) { if (event) {
runtimeState.eventNow = event; runtimeState.eventNow = event;
@@ -282,19 +321,39 @@ export function reload(event?: OntimeEvent) {
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
// handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) {
// if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) {
if (
runtimeState.eventNow.timeStart < runtimeState.clock &&
runtimeState.clock < runtimeState.eventNow.timeEnd
) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock);
}
}
}
return runtimeState.eventNow.id; return runtimeState.eventNow.id;
} }
// reset changes to timer progress
runtimeState.timer.playback = Playback.Armed; runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration; runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = null;
runtimeState.timer.startedAt = null; runtimeState.timer.startedAt = null;
runtimeState.timer.finishedAt = null; runtimeState.timer.finishedAt = null;
runtimeState._timer.pausedAt = null;
runtimeState.timer.addedTime = 0; runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null;
// this could be looked after by the timer
runtimeState.timer.elapsed = null;
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
runtimeState.currentBlock.startedAt = null; runtimeState.currentBlock.startedAt = null;
@@ -302,16 +361,13 @@ export function reload(event?: OntimeEvent) {
} }
/** /**
* Used in situations when we want to reload all events * Used in situations when we want to hot-reload all events without interrupting timer
* without interrupting timer
* @param eventNow
* @param playableEvents
* @param rundown
*/ */
export function reloadAll(eventNow: OntimeEvent, rundown: OntimeRundown) { export function reloadAll(rundown: OntimeRundown) {
loadNow(eventNow, rundown); const timedEvents = filterTimedEvents(rundown);
loadNext(rundown); loadNow(timedEvents);
reload(eventNow); loadNext(timedEvents);
reload(runtimeState.eventNow ?? undefined);
} }
export function start(state: RuntimeState = runtimeState): boolean { export function start(state: RuntimeState = runtimeState): boolean {
@@ -336,7 +392,6 @@ export function start(state: RuntimeState = runtimeState): boolean {
} }
if (state.currentBlock.startedAt === null) { if (state.currentBlock.startedAt === null) {
console.log('currentBlock.startedAt is null, setting new start');
state.currentBlock.startedAt = state.clock; state.currentBlock.startedAt = state.clock;
} }
@@ -381,11 +436,22 @@ export function stop(state: RuntimeState = runtimeState): boolean {
return true; return true;
} }
/**
* Exposes functionality to add user time to the timer externally
*/
export function addTime(amount: number) { export function addTime(amount: number) {
if (runtimeState.timer.current === null) { if (runtimeState.timer.current === null) {
return false; return false;
} }
// as long as there is a timer, we need an expected finish
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.timer.expectedFinish === null) {
throw new Error('runtimeState.addTime: invalid state received');
}
}
// handle edge cases // handle edge cases
// !!! we need to handle side effects before updating the state // !!! we need to handle side effects before updating the state
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current; const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
@@ -415,7 +481,7 @@ export function addTime(amount: number) {
export type UpdateResult = { export type UpdateResult = {
hasTimerFinished: boolean; hasTimerFinished: boolean;
shouldCallRoll: boolean; hasSecondaryTimerFinished: boolean;
}; };
export function update(): UpdateResult { export function update(): UpdateResult {
@@ -429,18 +495,21 @@ export function update(): UpdateResult {
// 2. are we waiting to roll? // 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
return updateIfWaitingToRoll(runtimeState.timer.secondaryTimer); return updateIfWaitingToRoll();
} }
// 3. at this point we know that we are playing an event // 3. at this point we know that we are playing an event
// reset data // reset data
runtimeState.timer.secondaryTimer = null; runtimeState.timer.secondaryTimer = null;
// update timer state // eslint-disable-next-line no-unused-labels -- dev code path
if (!runtimeState.timer.duration) { DEV: {
throw new Error('Timer duration is not set'); if (!runtimeState.timer.duration) {
throw new Error('runtimeState.update: invalid state received');
}
} }
// update timer state
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
runtimeState.timer.phase = getTimerPhase(runtimeState); runtimeState.timer.phase = getTimerPhase(runtimeState);
@@ -462,55 +531,128 @@ export function update(): UpdateResult {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
} }
return { hasTimerFinished: finishedNow, shouldCallRoll: finishedNow }; return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false };
function updateIfIdle() { function updateIfIdle() {
// if nothing is running, nothing to do // if nothing is running, nothing to do
return { hasTimerFinished: false, shouldCallRoll: false }; return { hasTimerFinished: false, hasSecondaryTimerFinished: false };
} }
function updateIfWaitingToRoll(targetTime: number) { function updateIfWaitingToRoll() {
runtimeState.timer.secondaryTimer = targetTime - runtimeState.clock; // eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
}
}
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
return { hasTimerFinished: false, shouldCallRoll: runtimeState.timer.secondaryTimer < 0 }; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer < 0 };
} }
} }
export function roll(rundown: OntimeRundown) { export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: boolean } {
const selectedEventIndex = runtimeState.runtime.selectedEventIndex; // 1. if an event is running, we simply take over the playback
const playableEvents = filterPlayable(rundown); if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) {
runtimeState.timer.playback = Playback.Roll;
return { eventId: runtimeState.eventNow?.id ?? null, didStart: false };
}
clear(); // 2. if there is an event armed, we use it
runtimeState.runtime.numEvents = playableEvents.length; if (runtimeState.timer.playback === Playback.Armed && runtimeState.eventNow !== null) {
runtimeState.timer.playback = Playback.Roll;
const { nextEvent, currentEvent } = getRollTimers(playableEvents, runtimeState.clock, selectedEventIndex);
if (currentEvent) {
// there is something running, load
runtimeState.timer.secondaryTimer = null;
// account for event that finishes the day after // account for event that finishes the day after
const endTime = const normalisedEndTime =
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd; runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime;
// when we load a timer in roll, we do the same things as before // state catch up
// but also pre-populate some data as to the running state runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
load(currentEvent, rundown, { runtimeState.timer.current = runtimeState.timer.duration;
startedAt: currentEvent.timeStart, runtimeState.timer.elapsed = 0;
expectedFinish: currentEvent.timeEnd,
current: endTime - runtimeState.clock, // check if the event is ready to start or if needs to be waited
}); const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, runtimeState.clock);
} else if (nextEvent) {
if (nextEvent.isPublic) { if (isNow) {
runtimeState.publicEventNext = nextEvent; runtimeState.timer.startedAt = runtimeState.clock;
// update runtime
if (!runtimeState.runtime.actualStart) {
runtimeState.runtime.actualStart = runtimeState.clock;
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
runtimeState.timer.phase = TimerPhase.Pending;
} }
runtimeState.eventNext = nextEvent;
// account for day after return { eventId: runtimeState.eventNow.id, didStart: isNow };
const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up
runtimeState.timer.phase = TimerPhase.Pending;
runtimeState.timer.secondaryTimer = nextStart - runtimeState.clock;
} }
// 3. if there is no event running, we need to find the next event
const timedEvents = filterTimedEvents(rundown);
if (timedEvents.length === 0) {
throw new Error('No playable events found');
}
clear();
const { index, isPending } = loadRoll(timedEvents, runtimeState.clock);
// load events in memory along with their data
loadNow(timedEvents, index);
loadNext(timedEvents, index);
// update roll state
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
runtimeState.runtime.numEvents = timedEvents.length;
// in roll mode spec, there should always be something to load
// as long as playableEvents is not empty
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null) {
throw new Error('runtimeState.roll: invalid state received');
}
}
if (isPending) {
// there is nothing now, but something coming up
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration;
return { eventId: runtimeState.eventNow.id, didStart: false };
}
// there is something to run, load event
// event will finish on time
// account for event that finishes the day after
const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = runtimeState.clock;
runtimeState.timer.expectedFinish = endTime;
// we add time to allow timer to catch up
runtimeState.timer.addedTime = -(runtimeState.clock - runtimeState.eventNow.timeStart);
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0;
// update runtime
runtimeState.runtime.actualStart = runtimeState.clock;
return { eventId: runtimeState.eventNow.id, didStart: true };
} }
+1
View File
@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": false,
"target": "ESNext", "target": "ESNext",
"module": "Node16", "module": "Node16",
"moduleResolution": "Node16", "moduleResolution": "Node16",
+63
View File
@@ -0,0 +1,63 @@
# ROLL Mode
Roll mode is intended to be a fully automatic playback that takes precedence over event end actions.
It can be user either on its own, or as in conjunction with manual playback to allow for automated rundown sections.
## Overview
- As long as there are non-skipped events in the rundown, we will always accept roll mode
- If there are no events in the current time frame, we load the next event and count-down to its start
- Roll will always load the first matching event in the current time, this could cause issues if there are multiple days planned or if the rundown is not in order.
- If we go from manual playback, to Roll mode, the playback should continue as is. Roll mode will automate loading the next event when the current is finished
## Implementation details
### starting to roll
> RuntimeService.roll(rundown: OntimeRundown)
When calling the roll function, we try and find events to load. There should always be an event as long as the rundown is not empty.
#### Taking over playback
If we are currently in "Play" mode and an event is playing, roll simply takes over playback. No other data changes are made
#### Starting an event
If there is nothing playing and roll finds an element that in the current time frame playing, it will start the event
#### Waiting to start
If we do not find an event that should be playing now, but find an event for the future, we load the next event, set roll mode and wait
### tick update
> RuntimeState.onUpdate()
Updating in roll mode attempts to have the least amount of custom logic in relation to normal updates. The only difference in behaviour is the automation of loading the next event when the current one is finished.
#### normal update
On the update of timers, there is no logic specific to Roll mode, all side effects (ie: integrations) should have the same behaviour as Play mode
#### waiting to start
If we are currently waiting to start, we just need to update the `secondaryTimer`.
If waiting to start is finished, we load the next event and start it
#### an event is finished
If an event is finished, we check if the next event is ready to start, this is similarly as if we had a conditional `load-next` `play-next` automation
If there is a gap between the events, we add `secondaryTimer` to match and wait for the next event to start
Finish time should account for `timer.addedTime`
Finish actions are ignored in roll mode
#### time has skipped
If we find that the new time update has slid in comparison to the old update (either too long, or time went backwards), we re-calculate
### Finding an event
> loadRoll(timedEvents: OntimeEvent[], timeNow: number)
This is a helper function which iterates trough a rundown to find the first matching element in the current time. As a trade-off, the wrong event will be loaded if the rundown is not in order.
It is important to note that all times in the rundown are in milliseconds from midnight. In the case of multiple days being scheduled, Roll will return the first match.
### Assumptions
The function receives a pre-filtered list of `TimedEvents`. This is to avoid issues with inconsistent index references.
- `TimedEvents` cannot be an empty array
- `TimedEvents` is assumed to be in order
### Specification
- we should always receive an `eventNow` or `eventNext`
- if the current clock is past the last event end, we roll for the events tomorrow
- if the current clock is before the first event, we do not load next day events, even if there is a match, we always start on first day
@@ -43,3 +43,5 @@ export type OntimeEvent = OntimeBaseEvent & {
timeDanger: number; timeDanger: number;
custom: EventCustomFields; custom: EventCustomFields;
}; };
export type PlayableEvent = OntimeEvent & { skip: false };
+9 -1
View File
@@ -8,6 +8,7 @@ export {
type OntimeDelay, type OntimeDelay,
type OntimeBlock, type OntimeBlock,
type OntimeEvent, type OntimeEvent,
type PlayableEvent,
SupportedEvent, SupportedEvent,
} from './definitions/core/OntimeEvent.type.js'; } from './definitions/core/OntimeEvent.type.js';
export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js'; export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
@@ -71,5 +72,12 @@ export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './defini
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js'; export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
// TYPE UTILITIES // TYPE UTILITIES
export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js'; export {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
isOntimeCycle,
isKeyOfType,
} from './utils/guards.js';
export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js'; export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js';
+5 -1
View File
@@ -1,4 +1,4 @@
import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../definitions/core/OntimeEvent.type.js'; import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js'; import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js';
@@ -10,6 +10,10 @@ export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent {
return event?.type === SupportedEvent.Event; return event?.type === SupportedEvent.Event;
} }
export function isPlayableEvent(event: OntimeEvent): event is PlayableEvent {
return !event.skip;
}
export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay { export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay {
return event?.type === SupportedEvent.Delay; return event?.type === SupportedEvent.Delay;
} }
+1
View File
@@ -71,6 +71,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic // feature business logic
// feature business logic - rundown // feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js'; export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js';
// feature business logic - spreadsheet import // feature business logic - spreadsheet import
@@ -0,0 +1,29 @@
import { checkIsNow } from './checkIsNow';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNow()', () => {
test('should return true if now is between timeStart and timeEnd', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 10;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
});
test('should return false if now is before start', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 8;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(false);
});
test('should return false if now is after end', () => {
const timeStart = 9;
const timeEnd = 16;
const now = 20;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(false);
});
test('should return true accounting for events that roll over midnight', () => {
expect(checkIsNow(22 * MILLIS_PER_HOUR, 8 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(true);
});
});
@@ -0,0 +1,9 @@
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event should be playing now
*/
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd;
return timeStart <= clock && clock <= normalisedEnd;
}
@@ -3,7 +3,6 @@ import { SupportedEvent } from 'ontime-types';
import { import {
filterPlayable, filterPlayable,
filterTimedEvents,
getLastEvent, getLastEvent,
getLastNormal, getLastNormal,
getNext, getNext,
@@ -300,24 +299,19 @@ describe('getLastEvent', () => {
}); });
}); });
describe('filter event', () => { describe('filterPlayable()', () => {
const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent; test('should return an array with only playable events', () => {
const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent; const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent;
const testRundown = [ const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent;
eventA, const testRundown = [
eventB, eventA,
{ id: 'c', type: SupportedEvent.Delay }, eventB,
{ id: 'd', type: SupportedEvent.Block }, { id: 'c', type: SupportedEvent.Delay },
]; { id: 'd', type: SupportedEvent.Block },
];
test('filterPlayable', () => {
const result = filterPlayable(testRundown as unknown as OntimeRundown); const result = filterPlayable(testRundown as unknown as OntimeRundown);
expect(result).toMatchObject([eventA]); expect(result).toMatchObject([eventA]);
}); });
test('filterTimedEvents', () => {
const result = filterTimedEvents(testRundown as unknown as OntimeRundown);
expect(result).toMatchObject([eventA, eventB]);
});
}); });
}); });
@@ -1,14 +1,19 @@
import type { NormalisedRundown, OntimeBlock, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import type {
import { isOntimeBlock, isOntimeEvent } from 'ontime-types'; NormalisedRundown,
OntimeBlock,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
PlayableEvent,
} from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, isPlayableEvent } from 'ontime-types';
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null }; type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
/** /**
* Gets first event in rundown, if it exists * Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeRundownEntry | null}
*/ */
export function getFirst(rundown: OntimeRundownEntry[]) { export function getFirst(rundown: OntimeRundown) {
return rundown.length ? rundown[0] : null; return rundown.length ? rundown[0] : null;
} }
@@ -25,16 +30,14 @@ export function getFirstNormal(rundown: NormalisedRundown, order: string[]) {
/** /**
* Gets first scheduled event in rundown, if it exists * Gets first scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/ */
export function getFirstEvent(rundown: OntimeRundownEntry[]): { export function getFirstEvent(rundown: OntimeRundown): {
firstEvent: OntimeEvent | null; firstEvent: PlayableEvent | null;
firstIndex: number | null; firstIndex: number | null;
} { } {
for (let i = 0; i < rundown.length; i++) { for (let i = 0; i < rundown.length; i++) {
const firstEvent = rundown[i]; const firstEvent = rundown[i];
if (isOntimeEvent(firstEvent) && !firstEvent.skip) { if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) {
return { firstEvent, firstIndex: i }; return { firstEvent, firstIndex: i };
} }
} }
@@ -43,21 +46,18 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]): {
/** /**
* Gets first scheduled event in a normalised rundown, if it exists * Gets first scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/ */
export function getFirstEventNormal( export function getFirstEventNormal(
rundown: NormalisedRundown, rundown: NormalisedRundown,
order: string[], order: string[],
): { ): {
firstEvent: OntimeEvent | null; firstEvent: PlayableEvent | null;
firstIndex: number | null; firstIndex: number | null;
} { } {
for (let i = 0; i < order.length; i++) { for (let i = 0; i < order.length; i++) {
const firstId = order[i]; const firstId = order[i];
const firstEvent = rundown[firstId]; const firstEvent = rundown[firstId];
if (isOntimeEvent(firstEvent) && !firstEvent.skip) { if (isOntimeEvent(firstEvent) && isPlayableEvent(firstEvent)) {
return { firstEvent, firstIndex: i }; return { firstEvent, firstIndex: i };
} }
} }
@@ -66,9 +66,6 @@ export function getFirstEventNormal(
/** /**
* Gets last event in a normalised rundown, if it exists * Gets last event in a normalised rundown, if it exists
* @param rundown
* @param order
* @returns
*/ */
export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null { export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null {
const lastId = order.at(-1); const lastId = order.at(-1);
@@ -80,11 +77,9 @@ export function getLastNormal(rundown: NormalisedRundown, order: string[]): Onti
/** /**
* Gets last scheduled event in rundown, if it exists * Gets last scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/ */
export function getLastEvent(rundown: OntimeRundown): { export function getLastEvent(rundown: OntimeRundown): {
lastEvent: OntimeEvent | null; lastEvent: PlayableEvent | null;
lastIndex: number | null; lastIndex: number | null;
} { } {
if (rundown.length < 1) { if (rundown.length < 1) {
@@ -93,7 +88,7 @@ export function getLastEvent(rundown: OntimeRundown): {
for (let i = rundown.length - 1; i >= 0; i--) { for (let i = rundown.length - 1; i >= 0; i--) {
const lastEvent = rundown.at(i); const lastEvent = rundown.at(i);
if (isOntimeEvent(lastEvent) && !lastEvent.skip) { if (isOntimeEvent(lastEvent) && isPlayableEvent(lastEvent)) {
return { lastEvent, lastIndex: i }; return { lastEvent, lastIndex: i };
} }
} }
@@ -102,9 +97,6 @@ export function getLastEvent(rundown: OntimeRundown): {
/** /**
* Gets last scheduled event in a normalised rundown, if it exists * Gets last scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } }
*/ */
export function getLastEventNormal( export function getLastEventNormal(
rundown: NormalisedRundown, rundown: NormalisedRundown,
@@ -129,12 +121,9 @@ export function getLastEventNormal(
/** /**
* Gets next entry in rundown, if it exists * Gets next entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ nextEvent: OntimeRundownEntry | null; nextIndex: number | null } }
*/ */
export function getNext( export function getNext(
rundown: OntimeRundownEntry[], rundown: OntimeRundown,
currentId: string, currentId: string,
): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } { ): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId); const index = rundown.findIndex((event) => event.id === currentId);
@@ -149,10 +138,6 @@ export function getNext(
/** /**
* Gets next entry in rundown, if it exists * Gets next entry in rundown, if it exists
* @param rundown
* @param order
* @param currentId
* @returns
*/ */
export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId); const currentIndex = order.findIndex((id) => id === currentId);
@@ -168,12 +153,9 @@ export function getNextNormal(rundown: NormalisedRundown, order: string[], curre
/** /**
* Gets next scheduled event in rundown, if it exists * Gets next scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } }
*/ */
export function getNextEvent( export function getNextEvent(
rundown: OntimeRundownEntry[], rundown: OntimeRundown,
currentId: string, currentId: string,
): { nextEvent: OntimeEvent | null; nextIndex: number | null } { ): { nextEvent: OntimeEvent | null; nextIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId); const index = rundown.findIndex((event) => event.id === currentId);
@@ -192,10 +174,6 @@ export function getNextEvent(
/** /**
* Gets next scheduled event in a normalised rundown, if it exists * Gets next scheduled event in a normalised rundown, if it exists
* @param rundown
* @param order
* @param {string} currentId
* @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } }
*/ */
export function getNextEventNormal( export function getNextEventNormal(
rundown: NormalisedRundown, rundown: NormalisedRundown,
@@ -219,10 +197,8 @@ export function getNextEventNormal(
/** /**
* Gets previous entry in rundown, if it exists * Gets previous entry in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
*/ */
export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): IndexAndEntry { export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAndEntry {
const currentIndex = rundown.findIndex((event) => event.id === currentId); const currentIndex = rundown.findIndex((event) => event.id === currentId);
if (currentIndex !== -1 && currentIndex - 1 >= 0) { if (currentIndex !== -1 && currentIndex - 1 >= 0) {
const index = currentIndex - 1; const index = currentIndex - 1;
@@ -235,9 +211,6 @@ export function getPrevious(rundown: OntimeRundownEntry[], currentId: string): I
/** /**
* Gets previous entry in a nornalised rundown, if it exists * Gets previous entry in a nornalised rundown, if it exists
* @param rundown
* @param order
* @param {string} currentId
*/ */
export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry {
const currentIndex = order.findIndex((id) => id === currentId); const currentIndex = order.findIndex((id) => id === currentId);
@@ -253,12 +226,9 @@ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], c
/** /**
* Gets previous scheduled event in rundown, if it exists * Gets previous scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } }
*/ */
export function getPreviousEvent( export function getPreviousEvent(
rundown: OntimeRundownEntry[], rundown: OntimeRundown,
currentId: string, currentId: string,
): { previousEvent: OntimeEvent | null; previousIndex: number | null } { ): { previousEvent: OntimeEvent | null; previousIndex: number | null } {
const index = rundown.findIndex((event) => event.id === currentId); const index = rundown.findIndex((event) => event.id === currentId);
@@ -302,8 +272,6 @@ export function getPreviousEventNormal(
/** /**
* @description swaps two OntimeEvents in the rundown * @description swaps two OntimeEvents in the rundown
* @param {OntimeEvent} eventA
* @param {OntimeEvent} eventB
*/ */
export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => { export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => {
const newA = { const newA = {
@@ -333,10 +301,6 @@ export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundow
/** /**
* Gets relevant block element for a given ID * Gets relevant block element for a given ID
* @param rundown
* @param order
* @param {string} currentId
* @return {OntimeBlock | null}
*/ */
export function getRelevantBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null { export function getRelevantBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null {
let inBlock = false; let inBlock = false;
@@ -357,16 +321,14 @@ export function getRelevantBlock(rundown: OntimeRundown, currentId: string): Ont
} }
/** /**
* returns all events that can be loaded * filters a rundown to timed events
* @return {array}
*/ */
export function filterPlayable(rundown: OntimeRundown): OntimeEvent[] { export function filterPlayable(rundown: OntimeRundown): PlayableEvent[] {
return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[]; return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as PlayableEvent[];
} }
/** /**
* returns all events of type OntimeEvent * filters a rundown to events that can be played
* @return {array}
*/ */
export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] { export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] {
return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[]; return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[];