refactor: runtime service (#715)

* refactor: rename PlaybackService > RuntimeService

* refactor: stateful runtime service

* refactor: merge event loading

* refactor: roll is part of state mutation
This commit is contained in:
Carlos Valente
2024-01-16 19:54:40 +01:00
committed by GitHub
parent ee121bcfcb
commit a2950442d8
27 changed files with 2198 additions and 2467 deletions
+3
View File
@@ -3,6 +3,9 @@ enum Source {
MIDI = 'MIDI',
}
/**
* Service manages retrieving current time from a managed time source
*/
class Clock {
private static instance: Clock;
private readonly source: Source;
@@ -8,6 +8,10 @@ interface Config {
lastLoadedProject: string;
}
/**
* Service manages Ontime's runtime configuration
*/
class ConfigService {
private config: Low<Config>;
private configPath: string;
-295
View File
@@ -1,295 +0,0 @@
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventStore } from '../stores/EventStore.js';
import { eventTimer } from './TimerService.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import { RestorePoint } from './RestoreService.js';
import { state } from '../state.js';
/**
* Service manages playback status of app
* Coordinating with necessary services
*/
export class PlaybackService {
/**
* makes calls for loading and starting given event
* @param {OntimeEvent} event
* @return {boolean} success
*/
static loadEvent(event: OntimeEvent): boolean {
let success = false;
if (!event) {
logger.error(LogOrigin.Playback, 'No event found');
} else if (event.skip) {
logger.warning(LogOrigin.Playback, `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
success = true;
}
eventStore.broadcast();
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static startById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success
*/
static startByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static startByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static loadById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success
*/
static loadByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static loadByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
static loadPrevious() {
const previousEvent = eventLoader.findPrevious();
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${previousEvent.id}`);
}
}
}
/**
* Loads event after currently selected
* @param {string} [fallbackAction] - 'stop', 'pause'
* @return {boolean} success
*/
static loadNext(fallbackAction?: 'stop' | 'pause'): boolean {
const nextEvent = eventLoader.findNext();
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${nextEvent.id}`);
return true;
}
} else if (fallbackAction === 'stop') {
logger.info(LogOrigin.Playback, 'No next event found! Stopping playback');
PlaybackService.stop();
return false;
} else if (fallbackAction === 'pause') {
logger.info(LogOrigin.Playback, 'No next event found! Pausing playback');
PlaybackService.pause();
return false;
} else {
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
}
/**
* Starts playback on selected event
*/
static start() {
if (validatePlayback(state.playback).start) {
eventTimer.start();
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Starts playback on next event
* @param {string} [fallbackAction] - 'stop', 'pause'
*/
static startNext(fallbackAction?: 'stop' | 'pause') {
const success = PlaybackService.loadNext(fallbackAction);
if (success) {
PlaybackService.start();
}
}
/**
* Pauses playback on selected event
*/
static pause() {
if (validatePlayback(state.playback).pause) {
eventTimer.pause();
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Stops timer and unloads any events
*/
static stop() {
if (validatePlayback(state.playback).stop) {
eventLoader.reset();
eventTimer.stop();
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Reloads current event
*/
static reload() {
if (state.timer.selectedEventId) {
this.loadById(state.timer.selectedEventId);
}
}
/**
* Sets playback to roll
*/
static roll() {
if (EventLoader.getPlayableEvents()) {
const rollTimers = eventLoader.findRoll(clock.timeNow());
// nothing to play
if (rollTimers === null) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent } = rollTimers;
if (!currentEvent && !nextEvent) {
logger.warning(LogOrigin.Server, 'Roll: no events found');
PlaybackService.stop();
return;
}
eventTimer.roll(currentEvent, nextEvent);
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* @description resume playback state given a restore point
* @param restorePoint
*/
static resume(restorePoint: RestorePoint) {
const willResume = () => logger.info(LogOrigin.Server, 'Resuming playback');
if (restorePoint.playback === Playback.Roll) {
willResume();
PlaybackService.roll();
}
if (restorePoint.selectedEventId) {
const event = EventLoader.getEventWithId(restorePoint.selectedEventId);
// the db would have to change for the event not to exist
// we do not kow the reason for the crash, so we check anyway
if (!event) {
return;
}
eventLoader.loadEvent(event);
eventTimer.resume(event, restorePoint);
eventStore.broadcast();
return;
}
}
/**
* Adds time to current event
* @param {number} time - time to add in seconds
*/
static addTime(time: number) {
if (state.timer.selectedEventId) {
const timeInMs = time * 1000;
eventTimer.addTime(timeInMs);
timeInMs > 0
? logger.info(LogOrigin.Playback, `Added ${time} sec`)
: logger.info(LogOrigin.Playback, `Removed ${time} sec`);
}
}
/**
* Adds delay to current event
* @deprecated Use addTime
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime: number) {
this.addTime(delayTime * 60);
}
}
+21 -88
View File
@@ -1,15 +1,10 @@
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { OntimeEvent, Playback } from 'ontime-types';
import { logger } from '../classes/Logger.js';
import type { RestorePoint } from './RestoreService.js';
import { stateMutations, state } from '../state.js';
type initialLoadingData = {
startedAt?: number | null;
expectedFinish?: number | null;
current?: number | null;
};
/**
* Service manages Ontime's main timer
*/
export class TimerService {
private _interval: NodeJS.Timer;
private _updateInterval: number;
@@ -17,82 +12,17 @@ export class TimerService {
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
* @param {number} [timerConfig.updateInterval]
*/
constructor(timerConfig: { refresh: number; updateInterval: number }) {
this._refreshInterval = timerConfig.refresh;
this._updateInterval = timerConfig.updateInterval;
logger.info(LogOrigin.Server, 'Timer service started');
}
init() {
this._interval = setInterval(() => this.update(), this._refreshInterval);
}
/**
* Resumes a given playback state, same as load
* @param {RestorePoint} restorePoint
* @param {OntimeEvent} event
*/
resume(event: OntimeEvent, restorePoint: RestorePoint) {
stateMutations.timer.resume(event, restorePoint);
}
// TODO: can load and hotreload be merged?
/**
* Reloads information for currently running timer
* @param event
*/
hotReload(event: OntimeEvent | undefined) {
if (event === undefined) {
this.stop();
return;
}
// TODO: this is no longer correct
if (event.id !== state.timer.selectedEventId) {
// we only hot reload if the timer is the same
return;
}
if (event.skip) {
this.stop();
}
// TODO: check if any relevant information warrants update
stateMutations.timer.reload(event);
this.update(true);
}
/**
* Loads given timer to object
* @param {OntimeEvent} event
* @param {initialLoadingData} initialData
*/
load(event: OntimeEvent, initialData?: initialLoadingData) {
if (event.skip) {
throw new Error('Refuse load of skipped event');
}
stateMutations.timer.clear();
// TODO: does this replace the need for hot reload?
if (initialData) {
stateMutations.timer.patch(initialData);
}
stateMutations.timer.load(event);
this._interval = setInterval(this.update, 32);
}
start() {
if (!state.timer.selectedEventId) {
// TODO: we should be able to start
if (state.playback === Playback.Roll) {
logger.error(LogOrigin.Playback, 'Cannot start while waiting for event');
}
if (!state.runtime.selectedEventId) {
return;
}
@@ -100,6 +30,8 @@ export class TimerService {
return;
}
// TODO: when we start a timer, we schedule an update to its expected end - 16ms
// we need to cancel this timer on pause, stop and addTime
stateMutations.timer.start();
}
@@ -122,33 +54,34 @@ export class TimerService {
* @param {number} amount
*/
addTime(amount: number) {
if (amount === 0) {
return;
}
if (state.timer.selectedEventId === null) {
if (state.runtime.selectedEventId === null) {
return;
}
stateMutations.timer.addTime(amount);
}
/**
* Update the app at regular intervals
* @param {boolean} force whether we should force a broadcast of state
*/
update(force = false) {
stateMutations.timer.update(force, this._updateInterval);
}
/**
* Loads roll information into timer service
* @param {OntimeEvent | null} currentEvent -- both current event and next event cant be null
* @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null
* @throws {Error} if rundown is empty
* @param {OntimeEvent[]} rundown -- list of events to run
*/
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) {
stateMutations.timer.roll(currentEvent, nextEvent);
this.update();
roll(rundown: OntimeEvent[]) {
if (rundown.length === 0) {
throw new Error('No events found');
}
stateMutations.timer.roll(rundown);
}
shutdown() {
clearInterval(this._interval);
}
}
// calculate at 30fps, refresh at 1fps
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 });
@@ -1,706 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { getRollTimers, normaliseEndTime, sortArrayByProperty, updateRoll } from '../rollUtils.js';
// test sortArrayByProperty()
describe('sort simple arrays of objects', () => {
it('sort array 1-5', () => {
const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }];
const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
it('sort array 1-5 with null', () => {
const arr1 = [
{ timeStart: 1 },
{ timeStart: 5 },
{ timeStart: 3 },
{ timeStart: 2 },
{ timeStart: 4 },
{ timeStart: null },
];
const arr1Expected = [
{ timeStart: null },
{ timeStart: 1 },
{ timeStart: 2 },
{ timeStart: 3 },
{ timeStart: 4 },
{ timeStart: 5 },
];
const sorted = sortArrayByProperty(arr1, 'timeStart');
expect(sorted).toStrictEqual(arr1Expected);
});
});
// test getRollTimers()
describe('test that roll loads selection in right order', () => {
const eventlist: Partial<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('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);
});
});
// test getRollTimers()
describe('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);
});
});
// test getRollTimers() on issue #58
describe('test that roll behaviour multi day event edge cases', () => {
it('if the start time is the day after end time, and start time is earlier than now', () => {
const now = 66600000; // 19:30
const eventlist: Partial<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() on issue #58
test('test typical scenarios', () => {
const t1 = {
start: 10,
end: 20,
};
const t1_expected = 20;
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
const t2 = {
start: 10 + dayInMs,
end: 20,
};
const t2_expected = 20 + dayInMs;
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
const t3 = {
start: 10,
end: 10,
};
const t3_expected = 10;
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
});
// test updateRoll()
describe('typical scenarios', () => {
it('it updates running events correctly', () => {
const timers = {
selectedEventId: '1',
current: 10,
_finishAt: 15,
clock: 11,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: timers._finishAt - timers.clock,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
// test that it can jump time
timers._finishAt = 1000;
timers.clock = 600;
expected.updatedTimer = timers._finishAt - timers.clock;
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('it updates secondary timer', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 11,
secondaryTimer: 1,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('flags an event end', () => {
const timers = {
selectedEventId: '1',
current: 10,
_finishAt: 11,
clock: 12,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: -1,
updatedSecondaryTimer: null,
doRollLoad: true,
isFinished: true,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('secondary events do not trigger event ends', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 16,
secondaryTimer: 1,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('when a secondary timer is finished, it prompts for new event load', () => {
const timers = {
selectedEventId: null,
current: null,
_finishAt: null,
clock: 15,
secondaryTimer: 0,
secondaryTarget: 15,
};
const expected = {
updatedTimer: null,
updatedSecondaryTimer: timers.secondaryTarget - timers.clock,
doRollLoad: true,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('counts over midnight', () => {
const timers = {
selectedEventId: '1',
current: 25,
_finishAt: 10 + dayInMs,
clock: dayInMs - 10,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: 20,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
it('rolls over midnight', () => {
const timers = {
selectedEventId: '1',
current: dayInMs,
_finishAt: 10 + dayInMs,
clock: 10,
secondaryTimer: null,
secondaryTarget: null,
};
const expected = {
updatedTimer: dayInMs,
updatedSecondaryTimer: null,
doRollLoad: false,
isFinished: false,
};
expect(updateRoll(timers)).toStrictEqual(expected);
});
});
File diff suppressed because it is too large Load Diff
-202
View File
@@ -1,202 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* @description Sorts an array of objects by given property
* @param {array} arr - array to be sorted
* @param {string} property - property to compare
* @returns {array} copy of array sorted in ascending order
*/
export const sortArrayByProperty = <T>(arr: T[], property: string): T[] => {
return [...arr].sort((a, b) => {
return a[property] - b[property];
});
};
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
let nextIndex: number | null = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
const lastEvent = orderedEvents[orderedEvents.length - 1];
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent: OntimeEvent | null = null;
let nextPublicEvent: OntimeEvent | null = null;
let currentEvent: OntimeEvent | null = null;
let currentPublicEvent: OntimeEvent | null = null;
if (timeNow > lastNormalEnd) {
// we are past last end
// preload first and find next
const firstEvent = orderedEvents[0];
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + dayInMs - timeNow;
if (firstEvent.isPublic) {
nextPublicEvent = firstEvent;
publicNextIndex = 0;
} else {
// look for next public
// dev note: we feel that this is more efficient than filtering
// since the next event will likely be close to the one playing
for (const event of orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
}
} else {
// flags: select first event if several overlapping
let nowFound = false;
// keep track of the end times when looking for public
let publicTime = -1;
for (const event of orderedEvents) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
const hasNotEnded = normalEnd > timeNow;
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (normalEnd <= timeNow) {
// event ran already
if (event.isPublic && normalEnd > publicTime) {
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow) {
// event will run
// we already know whats next and next-public
if (nextIndex !== null && publicNextIndex !== null) {
continue;
}
// look for next events
// check how far the start is from now
const timeToEventStart = event.timeStart - timeNow;
// we don't have a next or this one starts sooner than current next
if (nextIndex === null || timeToEventStart < timeToNext) {
timeToNext = timeToEventStart;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if (event.isPublic) {
// if we don't have a public next or this one start sooner than assigned next
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
publicTimeToNext = timeToEventStart;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
type CurrentTimers = {
selectedEventId: string | null;
current: number | null;
_finishAt: number | null;
clock: number | null;
secondaryTimer: number | null;
secondaryTarget: number | null;
};
/**
* @description Implements update functions for roll mode
* @param {CurrentTimers} currentTimers
* @returns {object} object with selection variables
*/
export const updateRoll = (currentTimers: CurrentTimers) => {
const { selectedEventId, current, _finishAt, clock, secondaryTimer, secondaryTarget } = currentTimers;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called: force reload of events
let doRollLoad = false;
// whether finished event should trigger
let isPrimaryFinished = false;
if (selectedEventId && current !== null) {
// if we have something selected and a timer, we are running
updatedTimer = _finishAt - clock;
if (updatedTimer > dayInMs) {
updatedTimer -= dayInMs;
}
if (updatedTimer < 0) {
isPrimaryFinished = true;
// we need a new event
doRollLoad = true;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
updatedSecondaryTimer = secondaryTarget - clock;
if (updatedSecondaryTimer <= 0) {
// we need a new event
doRollLoad = true;
}
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
};
@@ -1,18 +1,9 @@
import {
LogOrigin,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
Playback,
SupportedEvent,
} from 'ontime-types';
import { LogOrigin, OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from 'ontime-types';
import { generateId, getCueCandidate } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { MAX_EVENTS } from '../../settings.js';
import { EventLoader, eventLoader } from '../../classes/event-loader/EventLoader.js';
import { eventTimer } from '../TimerService.js';
import { EventLoader } from '../../classes/event-loader/EventLoader.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { runtimeCacheStore } from '../../stores/cachingStore.js';
import {
@@ -28,130 +19,17 @@ import {
} from './delayedRundown.utils.js';
import { logger } from '../../classes/Logger.js';
import { validateEvent } from '../../utils/parser.js';
import { clock } from '../Clock.js';
import { state } from '../../state.js';
import { stateMutations } from '../../state.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
/**
* Forces rundown to be recalculated
* To be used when we know the rundown has changed completely
*/
export function forceReset() {
eventLoader.reset();
runtimeService.reset();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
/**
* Checks if a list of IDs is in the current selection
*/
const affectedLoaded = (affectedIds: string[]) => {
const now = eventLoader.loaded.selectedEventId;
const nowPublic = eventLoader.loaded.selectedPublicEventId;
const next = eventLoader.loaded.nextEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
};
/**
* Checks if timer replaces the loaded next
*/
const isNewNext = () => {
const timedEvents = EventLoader.getTimedEvents();
const now = eventLoader.loaded.selectedEventId;
const next = eventLoader.loaded.nextEventId;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
if (indexNext - indexNow !== 1) {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = eventLoader.loaded.selectedPublicEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
}
return foundNew;
};
/**
* Updates timer service when a relevant piece of data changes
*/
export function updateTimer(affectedIds?: string[]) {
const runningEventId = eventLoader.loaded.selectedEventId;
const nextEventId = eventLoader.loaded.nextEventId;
if (runningEventId === null && nextEventId === null) {
return false;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
const eventInMemory = safeOption ? false : affectedLoaded(affectedIds);
// 3. the edited event replaces next event
const isNext = isNewNext();
if (safeOption) {
eventLoader.reset();
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
return true;
}
if (eventInMemory) {
eventLoader.reset();
if (state.playback === Playback.Roll) {
const rollTimers = eventLoader.findRoll(clock.timeNow());
if (rollTimers === null) {
eventTimer.stop();
} else {
const { currentEvent, nextEvent } = rollTimers;
eventTimer.roll(currentEvent, nextEvent);
}
} else {
const { eventNow } = eventLoader.loadById(runningEventId) || {};
if (eventNow) {
eventTimer.hotReload(eventNow);
} else {
eventTimer.stop();
}
}
return true;
}
if (isNext) {
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
return true;
}
return false;
}
/**
* @description creates a new event with given data
* @param {object} eventData
@@ -216,8 +94,8 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
await cachedBatchEdit(ids, data);
// notify timer service of changed events
updateTimer(ids);
// notify runtime service of changed events
runtimeService.update(ids);
// advice socket subscribers of change
sendRefetch();
@@ -243,7 +121,8 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() {
await cachedClear();
notifyChanges({ timer: true, external: true, reset: true });
// no need to modify timer since we will reset
notifyChanges({ external: true, reset: true });
}
/**
@@ -284,7 +163,8 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object
*/
function updateChangeNumEvents() {
eventLoader.updateNumEvents();
const numEvents = EventLoader.getPlayableEvents().length;
stateMutations.updateNumEvents(numEvents);
}
/**
@@ -293,10 +173,11 @@ function updateChangeNumEvents() {
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
if (options.timer) {
// notify timer service of changed events
// timer can be true or an array of changed IDs
if (Array.isArray(options.timer)) {
updateTimer(options.timer);
runtimeService.update(options.timer);
}
updateTimer();
runtimeService.update();
}
if (options.reset) {
@@ -0,0 +1,352 @@
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils';
import { EventLoader } from '../../classes/event-loader/EventLoader.js';
import { TimerService } from '../TimerService.js';
import { logger } from '../../classes/Logger.js';
import { RestorePoint } from '../RestoreService.js';
import { state, stateMutations } from '../../state.js';
/**
* Service manages runtime status of app
* Coordinating with necessary services
*/
class RuntimeService {
private eventTimer: TimerService;
constructor() {}
init(resumable: RestorePoint | null) {
logger.info(LogOrigin.Server, 'Runtime service started');
// TODO: refresh at 32ms, slowing down now to keep UI responsive while we dont have granular updates
// calculate at 30fps, refresh at 1fps
this.eventTimer = new TimerService({ refresh: 1000, updateInterval: 1000 });
if (resumable) {
this.resume(resumable);
}
}
shutdown() {
logger.info(LogOrigin.Server, 'Runtime service shutting down');
this.eventTimer.shutdown();
}
/**
* Checks if a list of IDs is in the current selection
*/
private affectsLoaded(affectedIds: string[]): boolean {
const now = state.runtime.selectedEventId;
const nowPublic = state.runtime.selectedPublicEventId;
const next = state.runtime.nextEventId;
const nextPublic = state.runtime.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
}
private isNewNext() {
const timedEvents = EventLoader.getPlayableEvents();
const now = state.runtime.selectedEventId;
const next = state.runtime.nextEventId;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
if (indexNext - indexNow !== 1) {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = state.runtime.selectedPublicEventId;
const nextPublic = state.runtime.nextPublicEventId;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
}
return foundNew;
}
reset() {
stateMutations.timer.clear();
}
/**
* check whether underlying data of runtime has changed
*/
update(affectedIds?: string[]) {
const hasLoadedElements = state.runtime.selectedEventId && state.runtime.nextEventId;
if (!hasLoadedElements) {
return;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event
const safeOption = typeof affectedIds === 'undefined';
// 2. the edited event is in memory (now or next) running
const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
// 3. the edited event replaces next event
let isNext = false;
if (safeOption || eventInMemory) {
if (state.playback === Playback.Roll) {
this.roll();
}
// load stuff again, but keep running if our events still exist
const eventNow = EventLoader.getEventWithId(state.runtime.selectedEventId);
if (eventNow) {
stateMutations.reload(eventNow);
}
return;
}
isNext = this.isNewNext();
if (isNext) {
// TODO: do i need to load here?
const playableEvents = EventLoader.getPlayableEvents();
stateMutations.loadNext(playableEvents);
}
}
/**
* makes calls for loading and starting given event
* @param {OntimeEvent} event
* @return {boolean} success - whether an event was loaded
*/
loadEvent(event: OntimeEvent): boolean {
if (event.skip) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false;
}
const timedEvents = EventLoader.getPlayableEvents();
stateMutations.load(event, timedEvents);
const success = event.id === state.runtime.selectedEventId;
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success - whether an event was loaded
*/
startById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = this.loadEvent(event);
if (success) {
this.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success - whether an event was loaded
*/
startByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = this.loadEvent(event);
if (success) {
this.start();
}
return success;
}
/**
* starts first event matching given cue
* @param {string} cue
* @return {boolean} success - whether an event was loaded
*/
startByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = this.loadEvent(event);
if (success) {
this.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success - whether an event was loaded
*/
loadById(eventId: string): boolean {
const event = EventLoader.getEventWithId(eventId);
const success = this.loadEvent(event);
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success - whether an event was loaded
*/
loadByIndex(eventIndex: number): boolean {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = this.loadEvent(event);
return success;
}
/**
* loads first event matching given cue
* @param {string} cue
* @return {boolean} success - whether an event was loaded
*/
loadByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = this.loadEvent(event);
return success;
}
/**
* Loads event before currently selected
* @return {boolean} success - whether an event was loaded
*/
loadPrevious(): boolean {
const previousEvent = EventLoader.findPrevious(state.runtime.selectedEventId);
if (previousEvent) {
const success = this.loadEvent(previousEvent);
return success;
}
return false;
}
/**
* Loads event after currently selected
* @return {boolean} success
*/
loadNext(): boolean {
const nextEvent = EventLoader.findNext(state.runtime.selectedEventId);
if (nextEvent) {
const success = this.loadEvent(nextEvent);
return success;
}
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
return false;
}
/**
* Starts playback on selected event
*/
start() {
const canStart = validatePlayback(state.playback).start;
if (canStart) {
this.eventTimer.start();
logger.info(LogOrigin.Playback, `Play Mode ${state.playback.toUpperCase()}`);
}
}
/**
* Starts playback on next event
*/
startNext() {
const hasNext = this.loadNext();
if (hasNext) {
this.start();
}
}
/**
* Pauses playback on selected event
*/
pause() {
if (validatePlayback(state.playback).pause) {
this.eventTimer.pause();
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Stops timer and unloads any events
*/
stop() {
if (validatePlayback(state.playback).stop) {
this.eventTimer.stop();
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
/**
* Reloads current event
*/
reload() {
if (state.runtime.selectedEventId) {
stateMutations.reload();
}
}
/**
* Sets playback to roll
*/
roll() {
const playableEvents = EventLoader.getPlayableEvents();
try {
this.eventTimer.roll(playableEvents);
} catch (error) {
logger.warning(LogOrigin.Server, `Roll: ${error}`);
}
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
/**
* @description resume playback state given a restore point
* @param restorePoint
*/
resume(restorePoint: RestorePoint) {
const { selectedEventId, playback } = restorePoint;
if (playback === Playback.Roll) {
this.roll();
}
// the db would have to change for the event not to exist
// we do not kow the reason for the crash, so we check anyway
const event = EventLoader.getEventWithId(selectedEventId);
if (!event) {
return;
}
const timedEvents = EventLoader.getPlayableEvents();
stateMutations.resume(restorePoint, event, timedEvents);
logger.info(LogOrigin.Playback, 'Resuming playback');
}
/**
* Adds time to current event
* @param {number} time - time to add in milliseconds
*/
addTime(time: number) {
this.eventTimer.addTime(time);
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
}
}
export const runtimeService = new RuntimeService();
+219 -39
View File
@@ -1,20 +1,22 @@
import { MaybeNumber, TimerType } from 'ontime-types';
import { MaybeNumber, OntimeEvent, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { TState } from '../state.js';
import { sortArrayByProperty } from '../utils/arrayUtils.js';
// TODO: timerUtils receive entire state object
/**
* handle events that span over midnight
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* Calculates expected finish time of a running timer
* @param {TState} state runtime state
* @returns {number | null} new current time or null if nothing is running
*/
export function getExpectedFinish(
startedAt: MaybeNumber,
finishedAt: MaybeNumber,
duration: number,
pausedTime: number,
addedTime: number,
timeEnd: number,
timerType: TimerType,
) {
export function getExpectedFinish(state: TState): MaybeNumber {
const { startedAt, clock, finishedAt, duration, addedTime, timerType, pausedAt } = state.timer;
const { timeEnd } = state.eventNow;
if (startedAt === null) {
return null;
}
@@ -23,12 +25,14 @@ export function getExpectedFinish(
return finishedAt;
}
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (timerType === TimerType.TimeToEnd) {
return timeEnd + addedTime + pausedTime;
}
// handle events that finish the day after
const expectedFinish = startedAt + duration + pausedTime + addedTime;
const expectedFinish = startedAt + duration + addedTime + pausedTime;
if (expectedFinish > dayInMs) {
return expectedFinish - dayInMs;
}
@@ -39,41 +43,41 @@ export function getExpectedFinish(
/**
* Calculates running countdown
* @param {TState} state runtime state
* @returns {number} current time for timer
*/
export function getCurrent(
startedAt: MaybeNumber,
duration: number,
addedTime: number,
pausedTime: number,
clock: number,
timeEnd: number,
timerType: TimerType,
) {
if (startedAt === null) {
return null;
}
export function getCurrent(state: TState): number {
const { startedAt, duration, addedTime, clock, timerType, pausedAt } = state.timer;
const { timeEnd } = state.eventNow;
if (timerType === TimerType.TimeToEnd) {
if (startedAt > timeEnd) {
return timeEnd + addedTime + pausedTime + dayInMs - clock;
}
return timeEnd + addedTime + pausedTime - clock;
const isNextDay = startedAt > timeEnd;
const correctDay = isNextDay ? dayInMs : 0;
return timeEnd + addedTime + correctDay - clock;
}
if (startedAt > clock) {
// we are the day after the event was started
return startedAt + duration + addedTime + pausedTime - clock - dayInMs;
if (startedAt === null) {
return duration;
}
return startedAt + duration + addedTime + pausedTime - clock;
const hasPassedMidnight = startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
}
return startedAt + duration + addedTime - clock - correctDay;
}
export function skippedOutOfEvent(
previousTime: number,
clock: number,
startedAt: number,
expectedFinish: number,
skipLimit: number,
): boolean {
/**
* Checks whether we have skipped out of the event
* @param {TState} state runtime state
* @param {number} previousTime previous clock
* @param {number} skipLimit how much time can we skip
* @returns {boolean}
*/
export function skippedOutOfEvent(state: TState, previousTime: number, skipLimit: number): boolean {
const { clock, startedAt, expectedFinish } = state.timer;
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
@@ -83,3 +87,179 @@ export function skippedOutOfEvent(
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
}
/**
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
let nextIndex: number | null = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
const orderedEvents = sortArrayByProperty(rundown, 'timeStart');
const lastEvent = orderedEvents[orderedEvents.length - 1];
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent: OntimeEvent | null = null;
let nextPublicEvent: OntimeEvent | null = null;
let currentEvent: OntimeEvent | null = null;
let currentPublicEvent: OntimeEvent | null = null;
if (timeNow > lastNormalEnd) {
// we are past last end
// preload first and find next
const firstEvent = orderedEvents[0];
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + dayInMs - timeNow;
if (firstEvent.isPublic) {
nextPublicEvent = firstEvent;
publicNextIndex = 0;
} else {
// look for next public
// dev note: we feel that this is more efficient than filtering
// since the next event will likely be close to the one playing
for (const event of orderedEvents) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
}
} else {
// flags: select first event if several overlapping
let nowFound = false;
// keep track of the end times when looking for public
let publicTime = -1;
for (const event of orderedEvents) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
const hasNotEnded = normalEnd > timeNow;
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
if (normalEnd <= timeNow) {
// event ran already
if (event.isPublic && normalEnd > publicTime) {
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
// it could also be public
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow) {
// event will run
// we already know whats next and next-public
if (nextIndex !== null && publicNextIndex !== null) {
continue;
}
// look for next events
// check how far the start is from now
const timeToEventStart = event.timeStart - timeNow;
// we don't have a next or this one starts sooner than current next
if (nextIndex === null || timeToEventStart < timeToNext) {
timeToNext = timeToEventStart;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if (event.isPublic) {
// if we don't have a public next or this one start sooner than assigned next
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
publicTimeToNext = timeToEventStart;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
}
}
return {
nowIndex,
nowId,
publicIndex,
nextIndex,
publicNextIndex,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
};
};
/**
* @description Implements update functions for roll mode
* @param {TState}
* @returns object with selection variables
*/
export const updateRoll = (state: TState) => {
const { selectedEventId } = state.runtime;
const { current, expectedFinish, startedAt, clock, secondaryTimer, secondaryTarget } = state.timer;
// timers
let updatedTimer = current;
let updatedSecondaryTimer = secondaryTimer;
// whether rollLoad should be called: force reload of events
let doRollLoad = false;
// whether finished event should trigger
let isPrimaryFinished = false;
if (selectedEventId && current !== null) {
// if we have something selected and a timer, we are running
const finishAt = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
updatedTimer = finishAt - clock;
if (updatedTimer > dayInMs) {
updatedTimer -= dayInMs;
}
if (updatedTimer < 0) {
isPrimaryFinished = true;
// we need a new event
doRollLoad = true;
}
} else if (secondaryTimer >= 0) {
// if secondaryTimer is running we are in waiting to roll
updatedSecondaryTimer = secondaryTarget - clock;
if (updatedSecondaryTimer <= 0) {
// we need a new event
doRollLoad = true;
}
}
return { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished: isPrimaryFinished };
};