refactor: setup global timer state (#629)

* refactor: setup global timer state

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Rohleder
2024-01-12 13:03:58 +01:00
committed by GitHub
parent e6bb52ad9c
commit f965db148c
12 changed files with 666 additions and 453 deletions
+11 -10
View File
@@ -7,6 +7,7 @@ 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
@@ -164,9 +165,9 @@ export class PlaybackService {
* Starts playback on selected event
*/
static start() {
if (validatePlayback(eventTimer.playback).start) {
if (validatePlayback(state.playback).start) {
eventTimer.start();
const newState = eventTimer.playback;
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -186,9 +187,9 @@ export class PlaybackService {
* Pauses playback on selected event
*/
static pause() {
if (validatePlayback(eventTimer.playback).pause) {
if (validatePlayback(state.playback).pause) {
eventTimer.pause();
const newState = eventTimer.playback;
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -197,10 +198,10 @@ export class PlaybackService {
* Stops timer and unloads any events
*/
static stop() {
if (validatePlayback(eventTimer.playback).stop) {
if (validatePlayback(state.playback).stop) {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -209,8 +210,8 @@ export class PlaybackService {
* Reloads current event
*/
static reload() {
if (eventTimer.loadedTimerId) {
this.loadById(eventTimer.loadedTimerId);
if (state.timer.selectedEventId) {
this.loadById(state.timer.selectedEventId);
}
}
@@ -237,7 +238,7 @@ export class PlaybackService {
eventTimer.roll(currentEvent, nextEvent);
const newState = eventTimer.playback;
const newState = state.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -274,7 +275,7 @@ export class PlaybackService {
* @param {number} time - time to add in seconds
*/
static addTime(time: number) {
if (eventTimer.loadedTimerId) {
if (state.timer.selectedEventId) {
const timeInMs = time * 1000;
eventTimer.addTime(timeInMs);
timeInMs > 0
+47 -424
View File
@@ -1,14 +1,8 @@
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
import { eventStore } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './timerUtils.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import type { RestorePoint } from './RestoreService.js';
import { stateMutations, state } from '../state.js';
type initialLoadingData = {
startedAt?: number | null;
@@ -16,302 +10,111 @@ type initialLoadingData = {
current?: number | null;
};
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
export const timeSkipLimit = 3 * 32;
export class TimerService {
private readonly _interval: NodeJS.Timer;
private _interval: NodeJS.Timer;
private _updateInterval: number;
private _lastUpdate: number | null;
private _skipThreshold: number;
private _refreshInterval: number;
playback: Playback;
timer: TimerState;
loadedTimerId: string | null;
private loadedTimerStart: number | null;
private loadedTimerEnd: number | null;
private pausedTime: number;
private pausedAt: number | null;
private secondaryTarget: number | null;
private saveRestorePoint: RestoreCallback;
/**
* @constructor
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
* @param {number} [timerConfig.updateInterval]
* @param {number} [timerConfig.skipThreshold]
*/
constructor(timerConfig: { refresh: number; updateInterval: number; skipThreshold: number }) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig.refresh);
constructor(timerConfig: { refresh: number; updateInterval: number }) {
this._refreshInterval = timerConfig.refresh;
this._updateInterval = timerConfig.updateInterval;
this._skipThreshold = timerConfig.skipThreshold;
logger.info(LogOrigin.Server, 'Timer service started');
}
/**
* Provides callback to save restore point
* @param cb
*/
setRestoreCallback(cb: RestoreCallback) {
this.saveRestorePoint = cb;
}
/**
* Clears internal state
* @private
*/
_clear() {
this.playback = Playback.Stop;
this.timer = {
clock: clock.timeNow(),
current: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
timeWarning: null,
timeDanger: null,
};
this.loadedTimerId = null;
this.loadedTimerStart = null;
this.loadedTimerEnd = null;
this.pausedTime = 0;
this.pausedAt = null;
this.secondaryTarget = null;
this._lastUpdate = null;
init() {
this._interval = setInterval(() => this.update(), this._refreshInterval);
}
/**
* Resumes a given playback state, same as load
* @param {RestorePoint} restorePoint
* @param {OntimeEvent} timer
* @param {OntimeEvent} event
*/
resume(timer: OntimeEvent, restorePoint: RestorePoint) {
this._clear();
// this is pretty much the same as load, with a few exceptions
this.loadedTimerId = timer.id;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.playback = restorePoint.playback;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.timer.startedAt = restorePoint.startedAt;
this.timer.addedTime = restorePoint.addedTime;
this.pausedTime = 0;
this.pausedAt = restorePoint.pausedAt;
this.timer.current = this.timer.duration;
if (this.timer.timerType === TimerType.TimeToEnd) {
const now = clock.timeNow();
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
this._onResume();
}
_onResume() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
resume(event: OntimeEvent, restorePoint: RestorePoint) {
stateMutations.timer.resume(event, restorePoint);
}
// TODO: can load and hotreload be merged?
/**
* Reloads information for currently running timer
* @param timer
* @param event
*/
hotReload(timer) {
if (typeof timer === 'undefined') {
hotReload(event: OntimeEvent | undefined) {
if (event === undefined) {
this.stop();
return;
}
if (timer?.id !== this.loadedTimerId) {
// event timer only concerns itself with current event
// TODO: this is no longer correct
if (event.id !== state.timer.selectedEventId) {
// we only hot reload if the timer is the same
return;
}
if (timer?.skip) {
if (event.skip) {
this.stop();
}
// TODO: check if any relevant information warrants update
stateMutations.timer.reload(event);
// update relevant information and force update
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
this.timer.timeWarning = timer.timeWarning;
this.timer.timeDanger = timer.timeDanger;
// this might not be ideal
this.timer.finishedAt = null;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
);
if (this.timer.startedAt === null) {
this.timer.current = this.timer.duration;
}
this.update(true);
}
/**
* Loads given timer to object
* @param {OntimeEvent} timer
* @param {OntimeEvent} event
* @param {initialLoadingData} initialData
*/
load(timer: OntimeEvent, initialData?: initialLoadingData) {
if (timer.skip) {
load(event: OntimeEvent, initialData?: initialLoadingData) {
if (event.skip) {
throw new Error('Refuse load of skipped event');
}
this._clear();
this.loadedTimerId = timer.id;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.playback = Playback.Armed;
this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction;
this.pausedTime = 0;
this.pausedAt = 0;
this.timer.timeWarning = timer.timeWarning;
this.timer.timeDanger = timer.timeDanger;
this.timer.current = this.timer.duration;
if (this.timer.timerType === TimerType.TimeToEnd) {
const now = clock.timeNow();
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
stateMutations.timer.clear();
// TODO: does this replace the need for hot reload?
if (initialData) {
this.timer = { ...this.timer, ...initialData };
stateMutations.timer.patch(initialData);
}
this._onLoad();
}
/**
* Handles side effects related to onLoad event
* @private
*/
_onLoad() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onLoad);
this._saveState();
stateMutations.timer.load(event);
}
start() {
if (!this.loadedTimerId) {
if (this.playback === Playback.Roll) {
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');
}
return;
}
if (this.playback === Playback.Play) {
if (state.playback === Playback.Play) {
return;
}
this.timer.clock = clock.timeNow();
this.timer.secondaryTimer = null;
this.secondaryTarget = null;
// add paused time if it exists
if (this.pausedTime) {
this.timer.addedTime += this.pausedTime;
this.pausedAt = null;
this.pausedTime = 0;
} else if (this.timer.startedAt === null) {
this.timer.startedAt = this.timer.clock;
}
this.playback = Playback.Play;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
);
this._onStart();
}
/**
* Handles side effects related to onStart event
* @private
*/
_onStart() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStart);
this._saveState();
stateMutations.timer.start();
}
pause() {
this.playback = Playback.Pause;
this.timer.clock = clock.timeNow();
this.pausedAt = this.timer.clock;
this._onPause();
}
_onPause() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onPause);
this._saveState();
if (state.playback !== Playback.Play) {
return;
}
stateMutations.timer.pause();
}
stop() {
if (this.playback === Playback.Stop) {
if (state.playback === Playback.Stop) {
return;
}
this._clear();
this._onStop();
}
_onStop() {
eventStore.batchSet({
playback: this.playback,
timer: this.timer,
});
integrationService.dispatch(TimerLifeCycle.onStop);
this._saveState();
stateMutations.timer.stop();
}
/**
@@ -319,152 +122,17 @@ export class TimerService {
* @param {number} amount
*/
addTime(amount: number) {
if (!this.loadedTimerId) {
if (amount === 0) {
return;
}
this.timer.addedTime += amount;
// handle edge cases
if (amount < 0 && Math.abs(amount) > this.timer.current) {
if (this.timer.finishedAt === null) {
// if we will make the clock negative
this.timer.finishedAt = clock.timeNow();
}
} else if (this.timer.current < 0 && this.timer.current + amount > 0) {
// clock will go from negative to positive
this.timer.finishedAt = null;
if (state.timer.selectedEventId === null) {
return;
}
// force an update
this.update(true);
this._saveState();
}
private updateRoll() {
const tempCurrentTimer = {
selectedEventId: this.loadedTimerId,
current: this.timer.current,
// safeguard on midnight rollover
_finishAt:
this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish
: this.timer.expectedFinish + dayInMs,
clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer,
secondaryTarget: this.secondaryTarget,
};
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer;
this.timer.elapsed = this.timer.duration - this.timer.current;
if (isFinished) {
this.timer.selectedEventId = null;
this.loadedTimerId = null;
this._onFinish();
}
// to load the next event we have to escalate to parent service
if (doRollLoad) {
PlaybackService.roll();
}
}
private updatePlay() {
if (this.playback === Playback.Pause) {
this.pausedTime = this.timer.clock - this.pausedAt;
}
const finishedNow = this.timer.current <= 0 && this.timer.finishedAt === null;
if (this.playback === Playback.Play && finishedNow) {
this.timer.finishedAt = this.timer.clock;
this._onFinish();
} else {
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
this.timer.duration,
this.pausedTime,
this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
);
}
this.timer.current = getCurrent(
this.timer.startedAt,
this.timer.duration,
this.timer.addedTime,
this.pausedTime,
this.timer.clock,
this.loadedTimerEnd,
this.timer.timerType,
);
this.timer.elapsed = this.timer.duration - this.timer.current;
stateMutations.timer.addTime(amount);
}
update(force = false) {
const previousTime = this.timer.clock;
this.timer.clock = clock.timeNow();
if (previousTime > this.timer.clock) {
force = true;
}
// we call integrations if we update timers
let shouldNotify = false;
if (this.playback === Playback.Roll) {
shouldNotify = true;
if (
skippedOutOfEvent(
previousTime,
this.timer.clock,
this.timer.startedAt,
this.timer.expectedFinish,
this._skipThreshold,
)
) {
PlaybackService.roll();
} else {
this.updateRoll();
}
} else if (this.timer.startedAt !== null) {
// we only update timer if a timer has been started
shouldNotify = true;
this.updatePlay();
}
// we only update the store at the updateInterval
// side effects such as onFinish will still be triggered in the update functions
if (force || this.timer.clock > this._lastUpdate + this._updateInterval) {
this._lastUpdate = this.timer.clock;
this._onUpdate(shouldNotify);
}
}
_onUpdate(shouldNotify: boolean) {
eventStore.set('timer', this.timer);
if (shouldNotify) {
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
}
_onFinish() {
eventStore.set('timer', this.timer);
integrationService.dispatch(TimerLifeCycle.onFinish);
if (this.playback === Playback.Play) {
if (this.timer.endAction === EndAction.Stop) {
PlaybackService.stop();
} else if (this.timer.endAction === EndAction.LoadNext) {
// we need to delay here to put this action in the queue stack. otherwise it won't be executed properly
setTimeout(() => {
PlaybackService.loadNext();
}, 0);
} else if (this.timer.endAction === EndAction.PlayNext) {
PlaybackService.startNext();
}
}
this._saveState();
stateMutations.timer.update(force, this._updateInterval);
}
/**
@@ -473,52 +141,8 @@ export class TimerService {
* @param {OntimeEvent | null} nextEvent -- both current event and next event cant be null
*/
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) {
this._clear();
this.timer.clock = clock.timeNow();
if (currentEvent) {
// there is something running, load
this.timer.secondaryTimer = null;
this.secondaryTarget = null;
// account for event that finishes the day after
const endTime =
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd;
// when we load a timer in roll, we do the same things as before
// but also pre-populate some data as to the running state
this.load(currentEvent, {
startedAt: currentEvent.timeStart,
expectedFinish: currentEvent.timeEnd,
current: endTime - this.timer.clock,
});
} else if (nextEvent) {
// account for day after
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up
this.timer.secondaryTimer = nextStart - this.timer.clock;
this.secondaryTarget = nextStart;
}
this.playback = Playback.Roll;
this._onRoll();
this.update(true);
}
_onRoll() {
eventStore.set('playback', this.playback);
this._saveState();
}
async _saveState() {
if (this.saveRestorePoint) {
await this.saveRestorePoint({
playback: this.playback,
selectedEventId: this.loadedTimerId,
startedAt: this.timer.startedAt,
addedTime: this.timer.addedTime,
pausedAt: this.pausedAt,
});
}
stateMutations.timer.roll(currentEvent, nextEvent);
this.update();
}
shutdown() {
@@ -527,5 +151,4 @@ export class TimerService {
}
// calculate at 30fps, refresh at 1fps
// we consider a skip at 3 lost updates
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000, skipThreshold: 32 * 3 });
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 });
@@ -28,6 +28,7 @@ import {
import { logger } from '../../classes/Logger.js';
import { validateEvent } from '../../utils/parser.js';
import { clock } from '../Clock.js';
import { state } from '../../state.js';
/**
* Forces rundown to be recalculated
@@ -123,7 +124,7 @@ export function updateTimer(affectedIds?: string[]) {
if (eventInMemory) {
eventLoader.reset();
if (eventTimer.playback === Playback.Roll) {
if (state.playback === Playback.Roll) {
const rollTimers = eventLoader.findRoll(clock.timeNow());
if (rollTimers === null) {
eventTimer.stop();
+2
View File
@@ -1,6 +1,8 @@
import { MaybeNumber, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
// TODO: timerUtils receive entire state object
/**
* Calculates expected finish time of a running timer
*/