mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 01:13:55 +00:00
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:
@@ -22,6 +22,8 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"passport": "^0.6.0",
|
||||
"passport-local": "~1.0.0",
|
||||
"steno": "^3.1.0",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+34
-9
@@ -1,4 +1,4 @@
|
||||
import { HttpSettings, LogOrigin, OSCSettings } from 'ontime-types';
|
||||
import { HttpSettings, LogOrigin, OSCSettings, Playback } from 'ontime-types';
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
@@ -38,11 +38,12 @@ import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from './services/integration-service/HttpIntegration.js';
|
||||
import { populateStyles } from './modules/loadStyles.js';
|
||||
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
||||
import { eventStore } from './stores/EventStore.js';
|
||||
import { PlaybackService } from './services/PlaybackService.js';
|
||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||
import { restoreService } from './services/RestoreService.js';
|
||||
import { messageService } from './services/message-service/MessageService.js';
|
||||
import { populateDemo } from './modules/loadDemo.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
@@ -158,17 +159,41 @@ export const startServer = async () => {
|
||||
|
||||
// load restore point if it exists
|
||||
const maybeRestorePoint = await restoreService.load();
|
||||
|
||||
if (maybeRestorePoint) {
|
||||
logger.info(LogOrigin.Server, 'Found resumable state');
|
||||
PlaybackService.resume(maybeRestorePoint);
|
||||
}
|
||||
eventTimer.init();
|
||||
|
||||
eventTimer.setRestoreCallback(async (newState: RestorePoint) => restoreService.save(newState));
|
||||
|
||||
// provide initial payload to event store
|
||||
const initialPayload = getInitialPayload();
|
||||
eventStore.init(initialPayload);
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
* Currently registered objects in store
|
||||
* - Timer Service timer
|
||||
* - Timer Service playback
|
||||
* - Message Service timerMessage
|
||||
* - Message Service publicMessage
|
||||
* - Message Service lowerMessage
|
||||
* - Message Service onAir
|
||||
* - Event Loader loaded
|
||||
* - Event Loader eventNow
|
||||
* - Event Loader publicEventNow
|
||||
* - Event Loader eventNext
|
||||
* - Event Loader publicEventNext
|
||||
*/
|
||||
eventStore.init({
|
||||
timer: state.timer,
|
||||
playback: state.playback,
|
||||
timerMessage: messageService.timerMessage,
|
||||
publicMessage: messageService.publicMessage,
|
||||
lowerMessage: messageService.lowerMessage,
|
||||
externalMessage: messageService.externalMessage,
|
||||
onAir: state.playback !== Playback.Stop,
|
||||
loaded: eventLoader.loaded,
|
||||
eventNow: eventLoader.eventNow,
|
||||
publicEventNow: eventLoader.publicEventNow,
|
||||
eventNext: eventLoader.eventNext,
|
||||
publicEventNext: eventLoader.publicEventNext,
|
||||
});
|
||||
|
||||
// eventStore set is a dependency of the services that publish to it
|
||||
messageService.init(eventStore.set.bind(eventStore));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -99,7 +99,7 @@ export const pathToStartDb = isTest
|
||||
? join(currentDirectory, '../', config.database.testdb, config.database.filename)
|
||||
: join(currentDirectory, '/preloaded-db/', config.database.filename);
|
||||
|
||||
//TODO: move all static files to the external directory
|
||||
// TODO: move all static files to the external directory
|
||||
// path to public styles
|
||||
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
|
||||
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import type { DeepReadonly, DeepWritable } from 'ts-essentials';
|
||||
|
||||
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { clock } from './services/Clock.js';
|
||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './services/timerUtils.js';
|
||||
import { eventStore } from './stores/EventStore.js';
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { PlaybackService } from './services/PlaybackService.js';
|
||||
import { updateRoll } from './services/rollUtils.js';
|
||||
|
||||
// TODO: move to timer config
|
||||
const timeSkipLimit = 3 * 32;
|
||||
|
||||
type TState = DeepReadonly<{
|
||||
playback: Playback;
|
||||
timer: TimerState & {
|
||||
pausedTime: number;
|
||||
pausedAt: number | null;
|
||||
timeEnd: number | null;
|
||||
finishedNow: boolean;
|
||||
lastUpdate: number | null;
|
||||
secondaryTarget: number | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
export const state: TState = {
|
||||
// QUESTION: should merge playback into the timer?
|
||||
playback: Playback.Stop,
|
||||
timer: {
|
||||
clock: clock.timeNow(),
|
||||
current: null,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
addedTime: 0,
|
||||
pausedTime: 0,
|
||||
pausedAt: null,
|
||||
timeEnd: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
secondaryTimer: null,
|
||||
selectedEventId: null,
|
||||
duration: null,
|
||||
timerType: null,
|
||||
endAction: null,
|
||||
lastUpdate: null,
|
||||
secondaryTarget: null,
|
||||
timeWarning: null,
|
||||
timeDanger: null,
|
||||
get finishedNow() {
|
||||
return this.current <= 0 && this.finishedAt === null;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const stateMutations = {
|
||||
timer: {
|
||||
// utility to reset the state of the timer
|
||||
clear() {
|
||||
mutate((state) => {
|
||||
// TODO: check that entire state is reset here
|
||||
state.playback = Playback.Stop;
|
||||
state.timer.clock = clock.timeNow();
|
||||
state.timer.current = null;
|
||||
state.timer.elapsed = null;
|
||||
state.timer.expectedFinish = null;
|
||||
state.timer.addedTime = 0;
|
||||
state.timer.startedAt = null;
|
||||
state.timer.finishedAt = null;
|
||||
state.timer.secondaryTimer = null;
|
||||
state.timer.selectedEventId = null;
|
||||
state.timer.duration = null;
|
||||
state.timer.timerType = null;
|
||||
state.timer.endAction = null;
|
||||
|
||||
state.timer.pausedTime = 0;
|
||||
state.timer.pausedAt = null;
|
||||
state.timer.secondaryTarget = null;
|
||||
});
|
||||
},
|
||||
// utility to allow modifying the state from the outside
|
||||
patch(timer: Partial<TimerState>) {
|
||||
mutate((state) => {
|
||||
for (const key in timer) {
|
||||
if (key in state.timer) {
|
||||
state.timer[key] = timer[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
start() {
|
||||
mutate(
|
||||
(state) => {
|
||||
// TODO: we need an event to start, should we get the event or the whole rundown?
|
||||
|
||||
state.timer.clock = clock.timeNow();
|
||||
state.timer.secondaryTimer = null;
|
||||
state.timer.secondaryTarget = null;
|
||||
|
||||
// add paused time if it exists
|
||||
if (state.timer.pausedTime) {
|
||||
state.timer.addedTime += state.timer.pausedTime;
|
||||
state.timer.pausedAt = null;
|
||||
state.timer.pausedTime = 0;
|
||||
} else if (state.timer.startedAt === null) {
|
||||
state.timer.startedAt = state.timer.clock;
|
||||
}
|
||||
|
||||
state.playback = Playback.Play;
|
||||
state.timer.expectedFinish = getExpectedFinish(
|
||||
state.timer.startedAt,
|
||||
state.timer.finishedAt,
|
||||
state.timer.duration,
|
||||
state.timer.pausedTime,
|
||||
state.timer.addedTime,
|
||||
state.timer.timeEnd,
|
||||
state.timer.timerType,
|
||||
);
|
||||
},
|
||||
{
|
||||
sideEffect() {
|
||||
integrationService.dispatch(TimerLifeCycle.onStart);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
stop() {
|
||||
mutate(
|
||||
(_state) => {
|
||||
stateMutations.timer.clear();
|
||||
},
|
||||
{
|
||||
sideEffect() {
|
||||
integrationService.dispatch(TimerLifeCycle.onStop);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
pause() {
|
||||
mutate(
|
||||
(state) => {
|
||||
// TODO: is it easier to have a beforeAll() function that sets the timer?
|
||||
state.playback = Playback.Pause;
|
||||
state.timer.clock = clock.timeNow();
|
||||
state.timer.pausedAt = state.timer.clock;
|
||||
},
|
||||
{
|
||||
sideEffect() {
|
||||
integrationService.dispatch(TimerLifeCycle.onPause);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
resume(event: OntimeEvent, restorePoint: RestorePoint) {
|
||||
mutate((state) => {
|
||||
state.timer.clock = clock.timeNow();
|
||||
|
||||
// TODO: the duplication of timer data would not be necessary
|
||||
// once event loader is merged here
|
||||
state.timer.selectedEventId = event.id;
|
||||
state.timer.startedAt = restorePoint.startedAt;
|
||||
state.timer.timeEnd = event.timeEnd;
|
||||
state.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
|
||||
state.timer.current = state.timer.duration;
|
||||
|
||||
state.timer.timerType = event.timerType;
|
||||
state.timer.endAction = event.endAction;
|
||||
|
||||
state.playback = restorePoint.playback;
|
||||
state.timer.pausedAt = restorePoint.pausedAt;
|
||||
state.timer.addedTime = restorePoint.addedTime;
|
||||
|
||||
// check if event finished meanwhile
|
||||
if (state.timer.timerType === TimerType.TimeToEnd) {
|
||||
state.timer.current = getCurrent(
|
||||
state.timer.startedAt,
|
||||
state.timer.duration,
|
||||
0,
|
||||
0,
|
||||
state.timer.clock,
|
||||
event.timeEnd,
|
||||
state.timer.timerType,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
load(event: OntimeEvent, patch?: Partial<TimerState>) {
|
||||
mutate(
|
||||
(state) => {
|
||||
// TODO: resume and load logic are very similar
|
||||
state.timer.clock = clock.timeNow();
|
||||
state.timer.selectedEventId = event.id;
|
||||
state.timer.timeEnd = event.timeEnd;
|
||||
|
||||
state.playback = Playback.Armed;
|
||||
state.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
|
||||
state.timer.timerType = event.timerType;
|
||||
state.timer.endAction = event.endAction;
|
||||
state.timer.timeWarning = event.timeWarning;
|
||||
state.timer.timeDanger = event.timeDanger;
|
||||
state.timer.pausedTime = 0;
|
||||
state.timer.pausedAt = 0; // TODO: should this not be null?
|
||||
|
||||
state.timer.current = state.timer.duration;
|
||||
if (state.timer.timerType === TimerType.TimeToEnd) {
|
||||
state.timer.current = getCurrent(
|
||||
state.timer.clock,
|
||||
state.timer.duration,
|
||||
0,
|
||||
0,
|
||||
state.timer.clock,
|
||||
event.timeEnd,
|
||||
state.timer.timerType,
|
||||
);
|
||||
}
|
||||
|
||||
if (patch) {
|
||||
state.timer = { ...state.timer, ...patch };
|
||||
}
|
||||
},
|
||||
{
|
||||
sideEffect() {
|
||||
integrationService.dispatch(TimerLifeCycle.onLoad);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
reload(timer: OntimeEvent) {
|
||||
mutate((state) => {
|
||||
state.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
|
||||
state.timer.timerType = timer.timerType;
|
||||
state.timer.endAction = timer.endAction;
|
||||
state.timer.timeEnd = timer.timeEnd;
|
||||
|
||||
state.timer.finishedAt = null;
|
||||
state.timer.expectedFinish = getExpectedFinish(
|
||||
state.timer.startedAt,
|
||||
state.timer.finishedAt,
|
||||
state.timer.duration,
|
||||
state.timer.pausedTime,
|
||||
state.timer.addedTime,
|
||||
state.timer.timeEnd,
|
||||
state.timer.timerType,
|
||||
);
|
||||
|
||||
if (state.timer.startedAt === null) {
|
||||
state.timer.current = state.timer.duration;
|
||||
}
|
||||
});
|
||||
},
|
||||
addTime(amount: number) {
|
||||
mutate((state) => {
|
||||
// TODO: what kinds of validation go here or in the consumer?
|
||||
if (!state.timer.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: remove pausedTime in favour of addedTime
|
||||
state.timer.addedTime += amount;
|
||||
|
||||
// handle edge cases
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > state.timer.current;
|
||||
if (willGoNegative) {
|
||||
if (state.timer.finishedAt === null) {
|
||||
state.timer.finishedAt = clock.timeNow();
|
||||
}
|
||||
} else {
|
||||
const willGoPositive = state.timer.current < 0 && state.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
state.timer.finishedAt = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// TODO: make options an object
|
||||
update(force: boolean, updateInterval: number) {
|
||||
return mutate(
|
||||
(state) => {
|
||||
function roll() {
|
||||
const hasSkippedOutOfEvent = skippedOutOfEvent(
|
||||
previousTime,
|
||||
state.timer.clock,
|
||||
state.timer.startedAt,
|
||||
state.timer.expectedFinish,
|
||||
timeSkipLimit,
|
||||
);
|
||||
if (hasSkippedOutOfEvent) {
|
||||
return { doRoll: true };
|
||||
}
|
||||
|
||||
const tempCurrentTimer = {
|
||||
selectedEventId: state.timer.selectedEventId,
|
||||
current: state.timer.current,
|
||||
// safeguard on midnight rollover
|
||||
_finishAt:
|
||||
state.timer.expectedFinish >= state.timer.startedAt
|
||||
? state.timer.expectedFinish
|
||||
: state.timer.expectedFinish + dayInMs,
|
||||
clock: state.timer.clock,
|
||||
secondaryTimer: state.timer.secondaryTimer,
|
||||
secondaryTarget: state.timer.secondaryTarget,
|
||||
};
|
||||
|
||||
const updated = updateRoll(tempCurrentTimer);
|
||||
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updated;
|
||||
state.timer.current = updatedTimer;
|
||||
state.timer.secondaryTimer = updatedSecondaryTimer;
|
||||
state.timer.elapsed = state.timer.duration - state.timer.current;
|
||||
|
||||
if (isFinished) {
|
||||
state.timer.selectedEventId = null;
|
||||
}
|
||||
|
||||
return { doRoll: doRollLoad, isFinished };
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (state.playback === Playback.Pause) {
|
||||
state.timer.pausedTime = state.timer.clock - state.timer.pausedAt;
|
||||
}
|
||||
|
||||
let isFinished = false;
|
||||
|
||||
if (state.playback === Playback.Play && state.timer.finishedNow) {
|
||||
state.timer.finishedAt = state.timer.clock;
|
||||
isFinished = true;
|
||||
} else {
|
||||
state.timer.expectedFinish = getExpectedFinish(
|
||||
state.timer.startedAt,
|
||||
state.timer.finishedAt,
|
||||
state.timer.duration,
|
||||
state.timer.pausedTime,
|
||||
state.timer.addedTime,
|
||||
state.timer.timeEnd,
|
||||
state.timer.timerType,
|
||||
);
|
||||
}
|
||||
|
||||
state.timer.current = getCurrent(
|
||||
state.timer.startedAt,
|
||||
state.timer.duration,
|
||||
state.timer.addedTime,
|
||||
state.timer.pausedTime,
|
||||
state.timer.clock,
|
||||
state.timer.timeEnd,
|
||||
state.timer.timerType,
|
||||
);
|
||||
|
||||
state.timer.elapsed = state.timer.duration - state.timer.current;
|
||||
|
||||
return { isFinished };
|
||||
}
|
||||
|
||||
// force indicates whether the state change should be broadcast to socket
|
||||
let _force = force;
|
||||
let _didUpdate = false;
|
||||
let _doRoll = false;
|
||||
let _isFinished = false;
|
||||
let _shouldNotify = false;
|
||||
|
||||
const previousTime = state.timer.clock;
|
||||
state.timer.clock = clock.timeNow();
|
||||
|
||||
if (previousTime > state.timer.clock) {
|
||||
_force = true;
|
||||
}
|
||||
|
||||
// we call integrations if we update timers
|
||||
if (state.playback === Playback.Roll) {
|
||||
const result = roll();
|
||||
_shouldNotify = true;
|
||||
_doRoll = result.doRoll;
|
||||
_isFinished = result.isFinished;
|
||||
} else if (state.timer.startedAt !== null) {
|
||||
// we only update timer if a timer has been started
|
||||
const result = play();
|
||||
_shouldNotify = true;
|
||||
_isFinished = result.isFinished;
|
||||
}
|
||||
|
||||
// we only update the store at the updateInterval
|
||||
// side effects such as onFinish will still be triggered in the update functions
|
||||
const isTimeToUpdate = state.timer.clock > state.timer.lastUpdate + updateInterval;
|
||||
if (_force || isTimeToUpdate) {
|
||||
state.timer.lastUpdate = state.timer.clock;
|
||||
// TODO: can we simplify the didUpdate and shouldNotify
|
||||
_didUpdate = true;
|
||||
}
|
||||
|
||||
return {
|
||||
didUpdate: _didUpdate,
|
||||
doRoll: _doRoll,
|
||||
isFinished: _isFinished,
|
||||
shouldNotify: _shouldNotify,
|
||||
};
|
||||
},
|
||||
{
|
||||
sideEffect({ didUpdate, doRoll, isFinished, shouldNotify }, newState) {
|
||||
// TODO: we cant cyclic calls to PlaybackService
|
||||
if (didUpdate && shouldNotify) {
|
||||
// TODO: can we distinguish between a clock update and a timer update?
|
||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||
}
|
||||
|
||||
if (doRoll) {
|
||||
PlaybackService.roll();
|
||||
}
|
||||
|
||||
if (isFinished) {
|
||||
integrationService.dispatch(TimerLifeCycle.onFinish);
|
||||
|
||||
// handle end action if there was a timer playing
|
||||
if (newState.playback === Playback.Play) {
|
||||
if (newState.timer.endAction === EndAction.Stop) {
|
||||
PlaybackService.stop();
|
||||
} else if (newState.timer.endAction === EndAction.LoadNext) {
|
||||
// we need to delay here to put this action in the queue stack. otherwise it won't be executed properly
|
||||
setTimeout(PlaybackService.loadNext, 0);
|
||||
} else if (newState.timer.endAction === EndAction.PlayNext) {
|
||||
PlaybackService.startNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
roll(currentEvent: OntimeEvent | null, nextEvent: OntimeEvent | null) {
|
||||
mutate((state) => {
|
||||
stateMutations.timer.clear();
|
||||
// TODO: should we have a pre-action that updates time in all mutations?
|
||||
state.timer.clock = clock.timeNow();
|
||||
|
||||
if (currentEvent) {
|
||||
// there is something running, load
|
||||
state.timer.secondaryTimer = null;
|
||||
state.timer.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 - state.timer.clock,
|
||||
});
|
||||
} else if (nextEvent) {
|
||||
// account for day after
|
||||
const nextStart =
|
||||
nextEvent.timeStart < state.timer.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
|
||||
// nothing now, but something coming up
|
||||
state.timer.secondaryTimer = nextStart - state.timer.clock;
|
||||
state.timer.secondaryTarget = nextStart;
|
||||
}
|
||||
state.playback = Playback.Roll;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is the only way to write to the state.
|
||||
* Mock this in the unit tests to assert state transitions and side effects.
|
||||
*/
|
||||
function mutate<R>(
|
||||
/**
|
||||
* A pure function that receives the current state and modifies it in place.
|
||||
* The function can return a value that will be passed to the side effects, and
|
||||
* will be returned to the caller of the mutation.
|
||||
*/
|
||||
fn: (state: DeepWritable<TState>) => R,
|
||||
opts?: {
|
||||
/**
|
||||
* Side effects are functions that execute code related to the change in state.
|
||||
*
|
||||
* @param result The result of the mutation function `fn`
|
||||
* @param newState The new state after the mutation
|
||||
* @param prevState The state before the mutation
|
||||
*/
|
||||
sideEffect?: (result: R, newState: TState, prevState: TState) => void;
|
||||
},
|
||||
) {
|
||||
const prevState = state;
|
||||
|
||||
const result = fn(state as DeepWritable<TState>);
|
||||
|
||||
const newState = state;
|
||||
|
||||
// run action specific side effect before global side effects
|
||||
opts?.sideEffect?.(result, newState, prevState);
|
||||
|
||||
// TODO: how would we handle granular updates
|
||||
// once more state is migrated here
|
||||
|
||||
// set eventStore any time a mutation happens
|
||||
// this means we are pushing this data to the client every 32ms
|
||||
eventStore.batchSet({
|
||||
playback: newState.playback,
|
||||
timer: newState.timer,
|
||||
});
|
||||
|
||||
// we write to restore service if the underlying data changes
|
||||
restoreService.save({
|
||||
playback: state.playback,
|
||||
selectedEventId: state.timer.selectedEventId,
|
||||
startedAt: state.timer.startedAt,
|
||||
addedTime: state.timer.addedTime,
|
||||
pausedAt: state.timer.pausedAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Playback, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { eventTimer } from '../services/TimerService.js';
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { eventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export type PublishFn = <T extends keyof RuntimeStore>(key: T, value: RuntimeStore[T]) => void;
|
||||
|
||||
@@ -65,9 +66,9 @@ export const eventStore = {
|
||||
*/
|
||||
|
||||
export const getInitialPayload = () => ({
|
||||
timer: eventTimer.timer,
|
||||
playback: eventTimer.playback,
|
||||
onAir: eventTimer.playback !== Playback.Stop,
|
||||
timer: state.timer,
|
||||
playback: state.playback,
|
||||
onAir: state.playback !== Playback.Stop,
|
||||
timerMessage: messageService.timerMessage,
|
||||
publicMessage: messageService.publicMessage,
|
||||
lowerMessage: messageService.lowerMessage,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js';
|
||||
import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js';
|
||||
|
||||
describe('getA1Notation()', () => {
|
||||
test('A1', () => {
|
||||
@@ -46,6 +47,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: '',
|
||||
revision: 0,
|
||||
id: '1358',
|
||||
timeWarning: 0,
|
||||
timeDanger: 0,
|
||||
};
|
||||
const metadata = {
|
||||
type: { row: 1, col: 14 },
|
||||
@@ -74,6 +77,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: { row: 1, col: 37 },
|
||||
revision: { row: 1, col: 38 },
|
||||
id: { row: 1, col: 39 },
|
||||
timeWarning: { row: 1, col: 40 },
|
||||
timeDanger: { row: 1, col: 41 },
|
||||
};
|
||||
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note);
|
||||
@@ -107,6 +112,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: '',
|
||||
revision: 0,
|
||||
id: '1358',
|
||||
timeWarning: 0,
|
||||
timeDanger: 0,
|
||||
};
|
||||
const metadata = {
|
||||
type: { row: 1, col: 14 },
|
||||
@@ -135,6 +142,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: { row: 1, col: 37 },
|
||||
revision: { row: 1, col: 38 },
|
||||
id: { row: 1, col: 39 },
|
||||
timeWarning: { row: 1, col: 40 },
|
||||
timeDanger: { row: 1, col: 41 },
|
||||
};
|
||||
const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue
|
||||
.stringValue;
|
||||
@@ -169,6 +178,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: 'u',
|
||||
revision: 0,
|
||||
id: '1358',
|
||||
timeWarning: 0,
|
||||
timeDanger: 0,
|
||||
};
|
||||
const metadata = {
|
||||
type: { row: 1, col: 14 },
|
||||
@@ -197,6 +208,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: { row: 1, col: 37 },
|
||||
revision: { row: 1, col: 38 },
|
||||
id: { row: 1, col: 39 },
|
||||
timeWarning: { row: 1, col: 40 },
|
||||
timeDanger: { row: 1, col: 41 },
|
||||
};
|
||||
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||
expect(result.updateCells.rows[0].values[11].userEnteredValue.stringValue).toStrictEqual('x');
|
||||
@@ -231,6 +244,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: 'u',
|
||||
revision: 0,
|
||||
id: '1358',
|
||||
timeWarning: 0,
|
||||
timeDanger: 0,
|
||||
};
|
||||
const metadata = {
|
||||
cue: { row: 1, col: 0 },
|
||||
@@ -272,6 +287,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: 'u',
|
||||
revision: 0,
|
||||
id: '1358',
|
||||
timeWarning: 0,
|
||||
timeDanger: 0,
|
||||
};
|
||||
const metadata = {
|
||||
cue: { row: 1, col: 5 },
|
||||
@@ -313,6 +330,8 @@ describe('cellRequenstFromEvent()', () => {
|
||||
user9: 'u',
|
||||
revision: 0,
|
||||
id: '1358',
|
||||
timeWarning: 0,
|
||||
timeDanger: 0,
|
||||
};
|
||||
const metadata = {
|
||||
cue: { row: 10, col: 5 },
|
||||
|
||||
@@ -8,7 +8,8 @@ test.describe('pages routes are available', () => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
await expect(page).toHaveTitle(/ontime/);
|
||||
await expect(page.getByTestId('editor-container')).toBeVisible();
|
||||
// TODO (v3): not ready yet
|
||||
// await expect(page.getByTestId('editor-container')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-messages-control')).toBeVisible();
|
||||
|
||||
Generated
+22
-1
@@ -294,6 +294,12 @@ importers:
|
||||
passport-local:
|
||||
specifier: ~1.0.0
|
||||
version: 1.0.0
|
||||
steno:
|
||||
specifier: ^3.1.0
|
||||
version: 3.2.0
|
||||
ts-essentials:
|
||||
specifier: ^9.4.1
|
||||
version: 9.4.1(typescript@5.2.2)
|
||||
ws:
|
||||
specifier: ^8.13.0
|
||||
version: 8.13.0
|
||||
@@ -8341,6 +8347,11 @@ packages:
|
||||
resolution: {integrity: sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==}
|
||||
dev: true
|
||||
|
||||
/steno@3.2.0:
|
||||
resolution: {integrity: sha512-zPKkv+LqoYffxrtD0GIVA08DvF6v1dW02qpP5XnERoobq9g3MKcTSBTi08gbGNFMNRo3TQV/6kBw811T1LUhKg==}
|
||||
engines: {node: '>=16'}
|
||||
dev: false
|
||||
|
||||
/steno@4.0.2:
|
||||
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -8625,6 +8636,17 @@ packages:
|
||||
typescript: 5.2.2
|
||||
dev: true
|
||||
|
||||
/ts-essentials@9.4.1(typescript@5.2.2):
|
||||
resolution: {integrity: sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.1.0'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
typescript: 5.2.2
|
||||
dev: false
|
||||
|
||||
/ts-node@10.9.1(@types/node@18.11.18)(typescript@5.2.2):
|
||||
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
|
||||
hasBin: true
|
||||
@@ -8824,7 +8846,6 @@ packages:
|
||||
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ufo@1.3.2:
|
||||
resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==}
|
||||
|
||||
Reference in New Issue
Block a user