V2 ws store (#310)

* Update TimerService.ts

* refactor: message service publishes to store

* refactor: several type improvements

* V2 ws store wss (#309)

* refactor: shared logging types

* refactor: simplify message service consumption

* refactor: create discrete logging system

* refactor: move socket.io > websocket
This commit is contained in:
Carlos Valente
2023-03-15 22:06:47 +01:00
committed by GitHub
parent 11648ee546
commit 76c8f8a4d5
86 changed files with 1876 additions and 1936 deletions
+3
View File
@@ -0,0 +1,3 @@
export interface IAdapter {
shutdown: () => void;
}
+54
View File
@@ -0,0 +1,54 @@
import { Server } from 'node-osc';
import { OSCSettings } from 'ontime-types';
import { IAdapter } from './IAdapter.js';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
export class OscServer implements IAdapter {
private osc: Server;
constructor(config: OSCSettings) {
this.osc = new Server(config.portIn, '0.0.0.0');
this.osc.on('error', console.error);
this.osc.on('message', (msg) => {
// message should look like /ontime/{path} {args} where
// ontime: fixed message for app
// path: command to be called
// args: extra data, only used on some API entries (delay, goto)
// split message
const [, address, path] = msg[0].split('/');
const args = msg[1];
// get first part before (ontime)
if (address !== 'ontime') {
logger.error('RX', `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
return;
}
// get second part (command)
if (!path) {
logger.error('RX', 'OSC IN: No path found');
return;
}
try {
const reply = dispatchFromAdapter(path, args, 'osc');
if (reply) {
const { topic, payload } = reply;
this.osc.emit(topic, payload);
}
} catch (error) {
logger.error('RX', `OSC IN: ${error}`);
}
});
}
shutdown() {
console.log('Shutting down OSC Server');
this.osc?.close();
}
}
@@ -0,0 +1,124 @@
/**
* DESIGN BY CONTRACT
* ===================
* All websocket calls are expected to follow the defined format,
* otherwise they will be ignored by Ontime server
*
* Messages should be in JSON format with two top level objects
* {
* type: ...
* payload: ...
* }
*
* Type: describes the action to be performed as enumerated in the API design
* Payload: adds necessary payload for the request to be completed
*/
import { WebSocket, WebSocketServer } from 'ws';
import getRandomName from '../utils/getRandomName.js';
import { IAdapter } from './IAdapter.js';
import { eventStore } from '../stores/EventStore.js';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
let instance;
export class SocketServer implements IAdapter {
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
private wss: WebSocketServer | null;
private clientIds: Set<string>;
constructor() {
if (instance) {
throw new Error('There can be only one');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.clientIds = new Set<string>();
this.wss = null;
}
init(server) {
this.wss = new WebSocketServer({ path: '/ws', server });
this.wss.on('connection', (ws) => {
const clientId = getRandomName();
this.clientIds.add(clientId);
logger.info('RX', `${this.wss.clients.size} Connections with new: ${clientId}`);
// send store payload on connect
ws.send(
JSON.stringify({
type: 'ontime',
payload: eventStore.poll(),
}),
);
ws.on('error', console.error);
ws.on('close', () => {
logger.info('RX', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
this.clientIds.delete(clientId);
});
ws.on('message', (data) => {
if (data.length > this.MAX_PAYLOAD) {
ws.close();
}
// TODO: protocol specific stuff should be handled here
// eg: rename-client
// socket.on('rename-client', (newName) => {
// if (newName) {
// const previousName = this._clientNames[socket.id];
// this._clientNames[socket.id] = newName;
// this.info('CLIENT', `Client ${previousName} renamed to ${newName}`);
// }
// });
try {
const message = JSON.parse(data);
const { type, payload } = message;
if (type === 'hello') {
ws.send('hi');
}
if (type === 'ontime-log') {
console.log('attempted adding to log');
}
try {
const reply = dispatchFromAdapter(type, payload, 'ws');
if (reply) {
const { topic, payload } = reply;
ws.send(topic, payload);
}
} catch (error) {
logger.error('RX', `WS IN: ${error}`);
}
} catch (_) {
// we ignore unknown
}
});
});
}
// message is any serializable value
send(message: any) {
this.wss?.clients.forEach((client) => {
if (client !== this.wss && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
shutdown() {
this.wss?.close();
}
}
export const socket = new SocketServer();
+33 -29
View File
@@ -6,8 +6,7 @@ import cors from 'cors';
// import utils
import { join, resolve } from 'path';
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
import { initSentry } from './modules/sentry.js';
import { initSentry, reportSentryException } from './modules/sentry.js';
import { currentDirectory, environment, isProduction, resolvedPath } from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { OSCSettings } from 'ontime-types';
@@ -18,13 +17,18 @@ import { router as eventDataRouter } from './routes/eventDataRouter.js';
import { router as ontimeRouter } from './routes/ontimeRouter.js';
import { router as playbackRouter } from './routes/playbackRouter.js';
// Services
// Import adapters
import { OscServer } from './adapters/OscAdapter.js';
import { socket } from './adapters/WebsocketAdapter.js';
import { DataProvider } from './classes/data-provider/DataProvider.js';
import { socketProvider } from './classes/socket/SocketController.js';
import { eventTimer } from './services/TimerService.js';
import { dbLoadingProcess } from './modules/loadDb.js';
// Services
import { eventTimer } from './services/TimerService.js';
import { integrationService } from './services/integration-service/IntegrationService.js';
import { OscIntegration } from './services/integration-service/OscIntegration.js';
import { logger } from './classes/Logger.js';
import { eventLoader } from './classes/event-loader/EventLoader.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -33,10 +37,7 @@ if (!isProduction) {
console.log(`Ontime directory at ${currentDirectory} `);
}
initSentry(environment);
// import socket provider
const socketServer = socketProvider;
initSentry(isProduction);
// Create express APP
const app = express();
@@ -100,6 +101,9 @@ enum OntimeStartOrder {
}
let step = OntimeStartOrder.InitDB;
let expressServer = null;
let oscServer = null;
const checkStart = (currentState: OntimeStartOrder) => {
if (step !== currentState) {
step = OntimeStartOrder.Error;
@@ -116,9 +120,6 @@ export const startDb = async () => {
await dbLoadingProcess;
};
// create HTTP server
const expressServer = http.createServer(app);
/**
* Starts servers
* @return {Promise<string>}
@@ -128,11 +129,13 @@ export const startServer = async () => {
const serverPort = 4001; // hardcoded for now
const returnMessage = `Ontime is listening on port ${serverPort}`;
expressServer.listen(serverPort, '0.0.0.0');
socketServer.initServer(expressServer);
socketServer.info('SERVER', returnMessage);
socketServer.startListener();
expressServer = http.createServer(app);
socket.init(expressServer);
eventLoader.init();
expressServer.listen(serverPort, '0.0.0.0');
return returnMessage;
};
@@ -149,7 +152,7 @@ export const startOSCServer = async (overrideConfig = null) => {
const { osc } = DataProvider.getData();
if (!osc.enabledIn) {
socketServer.info('RX', 'OSC Input Disabled');
logger.info('RX', 'OSC Input Disabled');
return;
}
@@ -160,8 +163,8 @@ export const startOSCServer = async (overrideConfig = null) => {
};
// Start OSC Server
socketServer.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`);
initiateOSC(oscSettings);
logger.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`);
oscServer = new OscServer(oscSettings);
};
/**
@@ -178,7 +181,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
const oscIntegration = new OscIntegration();
const { success, message } = oscIntegration.init(osc);
socketServer.info('RX', message);
logger.info('RX', message);
if (success) {
integrationService.register(oscIntegration);
@@ -193,25 +196,26 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
export const shutdown = async (exitCode = 0) => {
console.log(`Ontime shutting down with code ${exitCode}`);
expressServer.close();
shutdownOSCServer();
expressServer?.close();
oscServer?.shutdown();
eventTimer.shutdown();
socketServer.shutdown();
integrationService.shutdown();
logger.shutdown();
socket.shutdown();
process.exit(exitCode);
};
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
process.on('unhandledRejection', async (error, promise) => {
console.error(error, 'Error: unhandled rejection', promise);
socketServer.error('SERVER', 'Error: unhandled rejection');
process.on('unhandledRejection', async (error) => {
reportSentryException(error);
logger.error('SERVER', 'Error: unhandled rejection');
await shutdown(1);
});
process.on('uncaughtException', async (error, promise) => {
console.error(error, 'Error: uncaught exception', promise);
socketServer.error('SERVER', 'Error: uncaught exception');
process.on('uncaughtException', async (error) => {
reportSentryException(error);
logger.error('SERVER', 'Error: uncaught exception');
await shutdown(1);
});
+104
View File
@@ -0,0 +1,104 @@
import { Log, LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js';
import { isProduction } from '../setup.js';
import { socket } from '../adapters/WebsocketAdapter.js';
class Logger {
private queue: Log[];
constructor() {
this.queue = [];
}
/**
* Enabling setup logger after init
*/
init() {
this.queue.forEach((log) => {
this._push(log);
});
this.queue = [];
}
private addToQueue(log: Log) {
this.queue.push(log);
if (this.queue.length > 100) {
this.queue.pop();
}
}
/**
* Internal safe push method, adds log to queue if callback not available
* @param log
*/
private _push(log: Log) {
if (!isProduction) {
console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`);
}
try {
socket.send({
type: 'ontime-log',
payload: log,
});
} catch (_e) {
this.addToQueue(log);
}
}
/**
* Emits logging message
* @param level
* @param origin
* @param text
*/
emit(level, origin: string, text: string) {
const log = {
id: generateId(),
level,
origin,
text,
time: millisToString(clock.getSystemTime() || 0),
};
this._push(log);
}
/**
* Utility to emit logging message of type INFO
* @param origin
* @param text
*/
info(origin: string, text: string) {
this.emit(LogLevel.Info, origin, text);
}
/**
* Utility to emit logging message of type WARN
* @param origin
* @param text
*/
warning(origin: string, text: string) {
this.emit(LogLevel.Warn, origin, text);
}
/**
* Utility to emit logging message of type ERROR
* @param origin
* @param text
*/
error(origin: string, text: string) {
this.emit(LogLevel.Error, origin, text);
}
/**
* Shutdown logger
*/
shutdown() {
console.log('Shutting down logger');
this.queue = [];
}
}
export const logger = new Logger();
@@ -1,31 +1,17 @@
import { OntimeEvent, TitleBlock, Loaded } from 'ontime-types';
import { DataProvider } from '../data-provider/DataProvider.js';
import { getRollTimers } from '../../services/rollUtils.js';
import { eventStore } from '../../stores/EventStore.js';
let instance;
type TitleBlock = {
titleNow: string | null;
subtitleNow: string | null;
presenterNow: string | null;
noteNow: string | null;
titleNext: string | null;
subtitleNext: string | null;
presenterNext: string | null;
noteNext: string | null;
};
/**
* Manages business logic around loading events
*/
export class EventLoader {
loadedEvent: object | null;
numEvents: number | null;
selectedEventIndex: number | null;
selectedEventId: string | null;
selectedPublicEventId: string | null;
nextEventId: string | null;
nextPublicEventId: string | null;
loadedEvent: OntimeEvent | null;
loaded: Loaded;
titles: TitleBlock;
titlesPublic: TitleBlock;
@@ -36,7 +22,10 @@ export class EventLoader {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.reset(false);
}
init() {
this.reset();
this.loadedEvent = null;
}
@@ -122,16 +111,16 @@ export class EventLoader {
*/
findPrevious() {
const timedEvents = EventLoader.getPlayableEvents();
if (timedEvents === null || !timedEvents.length || this.selectedEventIndex === 0) {
if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === 0) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
if (this.loaded.selectedEventIndex === null) {
return timedEvents[0];
}
const newIndex = this.selectedEventIndex - 1;
const newIndex = this.loaded.selectedEventIndex - 1;
return timedEvents?.[newIndex];
}
@@ -141,15 +130,15 @@ export class EventLoader {
*/
findNext() {
const timedEvents = EventLoader.getPlayableEvents();
if (timedEvents === null || !timedEvents.length || this.selectedEventIndex === this.numEvents - 1) {
if (timedEvents === null || !timedEvents.length || this.loaded.selectedEventIndex === this.loaded.numEvents - 1) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
if (this.loaded.selectedEventIndex === null) {
return timedEvents[0];
}
const newIndex = this.selectedEventIndex + 1;
const newIndex = this.loaded.selectedEventIndex + 1;
return timedEvents?.[newIndex];
}
@@ -167,9 +156,9 @@ export class EventLoader {
getRollTimers(timedEvents, timeNow);
this.loadedEvent = currentEvent;
this.selectedEventIndex = nowIndex;
this.selectedEventId = currentEvent?.id || null;
this.numEvents = timedEvents.length;
this.loaded.selectedEventIndex = nowIndex;
this.loaded.selectedEventId = currentEvent?.id || null;
this.loaded.numEvents = timedEvents.length;
// titles
this._loadThisTitles(currentEvent, 'now-private');
@@ -187,28 +176,33 @@ export class EventLoader {
getLoaded() {
return {
loadedEvent: this.loadedEvent,
selectedEventIndex: this.selectedEventIndex,
selectedEventId: this.selectedEventId,
selectedPublicEventId: this.selectedPublicEventId,
nextEventId: this.nextEventId,
nextPublicEventId: this.nextPublicEventId,
numEvents: this.numEvents,
loaded: this.loaded,
titles: this.titles,
titlesPublic: this.titlesPublic,
};
}
/**
* Forces event loader to update the event count
*/
updateNumEvents() {
this.loaded.numEvents = EventLoader.getPlayableEvents().length;
eventStore.set('loaded', this.loaded);
}
/**
* Resets instance state
*/
reset(emit?: boolean) {
reset(emit = true) {
this.loadedEvent = null;
this.selectedEventIndex = null;
this.selectedEventId = null;
this.selectedPublicEventId = null;
this.nextEventId = null;
this.nextPublicEventId = null;
this.numEvents = null;
this.loaded = {
selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
numEvents: EventLoader.getPlayableEvents().length,
};
this.titles = {
titleNow: null,
subtitleNow: null,
@@ -250,9 +244,9 @@ export class EventLoader {
// we know some stuff now
this.loadedEvent = event;
this.selectedEventIndex = eventIndex;
this.selectedEventId = event.id;
this.numEvents = timedEvents.length;
this.loaded.selectedEventIndex = eventIndex;
this.loaded.selectedEventId = event.id;
this.loaded.numEvents = timedEvents.length;
// this.nextEventId = playableEvents[eventIndex + 1].id;
this._loadTitlesNow(event, playableEvents);
this._loadTitlesNext(playableEvents);
@@ -266,6 +260,7 @@ export class EventLoader {
* Handle side effects from event loading
*/
private _loadEvent() {
eventStore.set('loaded', this.loaded);
eventStore.set('titles', this.titles);
eventStore.set('titlesPublic', this.titlesPublic);
}
@@ -288,13 +283,13 @@ export class EventLoader {
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.selectedPublicEventId = null;
this.loaded.selectedPublicEventId = null;
// if there is nothing before, return
if (this.selectedEventIndex === 0) return;
if (this.loaded.selectedEventIndex === 0) return;
// iterate backwards to find it
for (let i = this.selectedEventIndex; i >= 0; i--) {
for (let i = this.loaded.selectedEventIndex; i >= 0; i--) {
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'now-public');
break;
@@ -309,27 +304,27 @@ export class EventLoader {
*/
private _loadTitlesNext(rundown) {
// maybe there is nothing to load
if (this.selectedEventIndex === null) return;
if (this.loaded.selectedEventIndex === null) return;
// assume there is no next event
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.nextEventId = null;
this.loaded.nextEventId = null;
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.nextPublicEventId = null;
this.loaded.nextPublicEventId = null;
const numEvents = rundown.length;
if (this.selectedEventIndex < numEvents - 1) {
if (this.loaded.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextPrivate = false;
for (let i = this.selectedEventIndex + 1; i < numEvents; i++) {
for (let i = this.loaded.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextPrivate) {
this._loadThisTitles(rundown[i], 'next-private');
@@ -367,14 +362,14 @@ export class EventLoader {
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.selectedPublicEventId = event.id;
this.loaded.selectedPublicEventId = event.id;
// private
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.selectedEventId = event.id;
this.loaded.selectedEventId = event.id;
break;
case 'now-public':
@@ -382,7 +377,7 @@ export class EventLoader {
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.selectedPublicEventId = event.id;
this.loaded.selectedPublicEventId = event.id;
break;
case 'now-private':
@@ -390,7 +385,7 @@ export class EventLoader {
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.selectedEventId = event.id;
this.loaded.selectedEventId = event.id;
break;
// next, load to both public and private
@@ -400,14 +395,14 @@ export class EventLoader {
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note;
this.nextPublicEventId = event.id;
this.loaded.nextPublicEventId = event.id;
// private
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.nextEventId = event.id;
this.loaded.nextEventId = event.id;
break;
case 'next-public':
@@ -415,7 +410,7 @@ export class EventLoader {
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note;
this.nextPublicEventId = event.id;
this.loaded.nextPublicEventId = event.id;
break;
case 'next-private':
@@ -423,7 +418,7 @@ export class EventLoader {
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.nextEventId = event.id;
this.loaded.nextEventId = event.id;
break;
default:
@@ -1,446 +0,0 @@
import { Server } from 'socket.io';
import { generateId } from 'ontime-utils';
import getRandomName from '../../utils/getRandomName.js';
import { stringFromMillis } from '../../utils/time.js';
import { messageManager } from '../message-manager/MessageManager.js';
import { PlaybackService } from '../../services/PlaybackService.js';
import { eventTimer } from '../../services/TimerService.js';
import { EventLoader, eventLoader } from '../event-loader/EventLoader.js';
import { clock } from '../../services/Clock.js';
class SocketController {
constructor() {
this.numClients = 0;
this.messageStack = [];
this._MAX_MESSAGES = 100;
this._clientNames = {};
this.socket = null;
}
initServer(httpServer) {
this.socket = new Server(httpServer, {
cors: {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
},
});
}
startListener() {
this._socketMessageHandler();
}
shutdown() {
this.info('SERVER', 'Shutting down ontime');
if (this.socket) {
this.info('TX', '... Closing socket server');
this.socket.close();
}
}
/**
* Handle socket io connections
* @private
*/
_socketMessageHandler() {
this.socket.on('connection', (socket) => {
/*******************************/
/*** HANDLE NEW CONNECTION ***/
/*** --------------------- ***/
/*******************************/
// keep track of connections
this.numClients++;
this._clientNames[socket.id] = getRandomName();
const message = `${this.numClients} Clients with new connection: ${this._clientNames[socket.id]}`;
this.info('CLIENT', message);
// Todo: review in favour of features
// send state
socket.emit('timer', eventTimer.timer);
socket.emit('playback', eventTimer.playback);
socket.emit('selected', {
id: eventLoader.selectedEventId,
index: eventLoader.selectedEventIndex,
total: eventLoader.numEvents,
});
socket.emit('next-id', eventLoader.nextEventId);
socket.emit('publicselected-id', eventLoader.selectedPublicEventId);
socket.emit('publicnext-id', eventLoader.nextPublicEventId);
/**
* @description handle disconnecting a user
*/
socket.on('disconnect', () => {
this.numClients--;
const message = `${this.numClients} Clients with disconnection: ${this._clientNames[socket.id]}`;
delete this._clientNames[socket.id];
this.info('CLIENT', message);
});
/**
* @description utility for renaming a user
*/
socket.on('rename-client', (newName) => {
if (newName) {
const previousName = this._clientNames[socket.id];
this._clientNames[socket.id] = newName;
this.info('CLIENT', `Client ${previousName} renamed to ${newName}`);
}
});
/***************************************/
/*** TIMER STATE GETTERS / SETTERS ***/
/*** ------- WEBSOCKET API ------- ***/
/*** ----------------------------- ***/
/***************************************/
/*******************************************/
socket.on('ontime-test', () => {
socket.emit('hello', socket.id);
});
socket.on('set-start', () => {
PlaybackService.start();
});
socket.on('set-startid', (data) => {
if (data) {
PlaybackService.startById(data);
}
});
socket.on('set-startindex', (data) => {
const eventIndex = Number(data);
if (!isNaN(eventIndex)) {
PlaybackService.startByIndex(eventIndex);
}
});
socket.on('set-loadid', (data) => {
if (data) {
PlaybackService.loadById(data);
}
});
socket.on('set-loadindex', (data) => {
const eventIndex = Number(data);
if (!isNaN(eventIndex)) {
PlaybackService.loadByIndex(eventIndex - 1);
}
});
socket.on('set-pause', () => {
PlaybackService.pause();
});
socket.on('set-stop', () => {
PlaybackService.stop();
});
socket.on('set-reload', () => {
PlaybackService.reload();
});
socket.on('set-previous', () => {
PlaybackService.loadPrevious();
});
socket.on('set-next', () => {
PlaybackService.loadNext();
});
socket.on('set-roll', () => {
PlaybackService.roll();
});
socket.on('set-delay', (data) => {
const delayTime = Number(data);
if (!isNaN(delayTime)) {
PlaybackService.setDelay(delayTime);
}
});
/*******************************************/
// general playback state, useful for external sync
// Todo: add delayed value (will come from rundownService)
socket.on('ontime-poll', () => {
const timerPoll = eventTimer.timer;
const isDelayed = false;
const colour = '';
socket.emit('ontime-poll', { isDelayed, colour, ...timerPoll });
});
/*******************************************/
socket.on('get-playback', () => {
socket.emit('playback', eventTimer.playback);
});
socket.on('get-onAir', () => {
socket.emit('onAir', messageManager.onAir);
});
/*******************************************/
socket.on('get-selected', () => {
socket.emit('selected', {
id: eventLoader.selectedEventId,
index: eventLoader.selectedEventIndex,
total: eventLoader.numEvents,
});
});
socket.on('get-titles', () => {
socket.emit('titles', eventLoader.titles);
});
socket.on('get-publictitles', () => {
socket.emit('publictitles', eventLoader.titlesPublic);
});
/***********************************/
/*** MESSAGE GETTERS / SETTERS ***/
/*** ------------------------- ***/
/***********************************/
// On Air
socket.on('set-onAir', (data) => {
if (typeof data === 'boolean') {
try {
const featureData = messageManager.setOnAir(data);
this.info('PLAYBACK', featureData.onAir ? 'Going On Air' : 'Going Off Air');
} catch (error) {
this.error('RX', `Failed to parse message ${data} : ${error}`);
}
}
});
// Presenter message
socket.on('set-timer-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
messageManager.setTimerText(data);
});
socket.on('set-timer-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
messageManager.setTimerVisibility(data);
});
/*******************************************/
// Public message
socket.on('set-public-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
messageManager.setPublicText(data);
});
socket.on('set-public-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
messageManager.setPublicVisibility(data);
});
/*******************************************/
// Lower third message
socket.on('set-lower-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
messageManager.setLowerText(data);
});
socket.on('set-lower-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
messageManager.setLowerVisibility(data);
});
/* MOLECULAR ENDPOINTS
* =====================
* 1. RUNDOWN
* 2. MESSAGE CONTROL
* 3. PLAYBACK CONTROL
* 4. INFO
* 5. CUE SHEET
* 6. TIMER OBJECT
* */
// 1. RUNDOWN
socket.on('get-feat-rundown', () => {
this.broadcastFeatureRundown();
});
// 2. MESSAGE CONTROL
socket.on('get-feat-messagecontrol', () => {
this.broadcastFeatureMessageControl();
});
// 3. PLAYBACK CONTROL
socket.on('get-feat-playbackcontrol', () => {
this.broadcastFeaturePlaybackControl();
});
// 4. INFO
socket.on('get-feat-info', () => {
this.broadcastFeatureInfo();
});
// 5. CUE SHEET
socket.on('get-feat-cuesheet', () => {
this.broadcastFeatureCuesheet();
});
// 6. TIMER
socket.on('get-timer', () => {
// TODO: Not ideal workaround
socket.emit('timer', eventTimer.timer);
});
});
}
send(topic, payload) {
this.socket?.emit(topic, payload);
}
/****************************************************************************/
/**
* Logger logic
* -------------
*/
/**
* Utility method, sends message and pushes into stack
* @param {string} level
* @param {string} origin
* @param {string} text
*/
_push(level, origin, text) {
const logMessage = {
id: generateId(),
level,
origin,
text,
time: stringFromMillis(clock.getSystemTime() || 0),
};
this.messageStack.unshift(logMessage);
this.socket?.emit('logger', logMessage);
if (process.env.NODE_ENV !== 'production') {
console.log(`[${logMessage.level}] \t ${logMessage.origin} \t ${logMessage.text}`);
}
if (this.messageStack.length > this._MAX_MESSAGES) {
this.messageStack.pop();
}
}
/**
* Broadcast data for Event List feature
*/
broadcastFeatureRundown() {
const featureData = {
selectedEventId: eventLoader.selectedEventId,
nextEventId: eventLoader.nextEventId,
playback: eventTimer.playback,
};
this.send('feat-rundown', featureData);
}
/**
* Broadcast data for Message Control feature
*/
broadcastFeatureMessageControl() {
const featureData = messageManager.getAll();
this.send('feat-messagecontrol', featureData);
}
/**
* Broadcast data for Playback Control feature
*/
broadcastFeaturePlaybackControl() {
const featureData = {
playback: eventTimer.playback,
selectedEventId: eventLoader.selectedEventId,
numEvents: EventLoader.getNumEvents(),
};
this.send('feat-playbackcontrol', featureData);
}
/**
* Broadcast data for Info feature
*/
broadcastFeatureInfo() {
const featureData = {
titles: eventLoader.titles,
playback: eventTimer.playback,
selectedEventId: eventLoader.selectedEventId,
selectedEventIndex: eventLoader.selectedEventIndex,
numEvents: EventLoader.getNumEvents(),
};
this.send('feat-info', featureData);
}
/**
* Broadcast data for Cuesheet feature
*/
broadcastFeatureCuesheet() {
const featureData = {
playback: eventTimer.playback,
selectedEventId: eventLoader.selectedEventId,
selectedEventIndex: eventLoader.selectedEventIndex,
numEvents: EventLoader.getNumEvents(),
titleNow: eventLoader.titles.titleNow,
};
this.send('feat-cuesheet', featureData);
}
// TODO: ouch, services should update the store
// make middleware to maintain the features OR remove the feature endpoints
broadcastState() {
this.broadcastFeatureRundown();
this.broadcastFeatureMessageControl();
this.broadcastFeaturePlaybackControl();
this.broadcastFeatureInfo();
this.broadcastFeatureCuesheet();
}
/**
* Sends a message with level LOG
* @param {string} origin
* @param {string} text
*/
info(origin, text) {
this._push('INFO', origin, text);
}
/**
* Sends a message with level WARN
* @param {string} origin
* @param {string} text
*/
warning(origin, text) {
this._push('WARN', origin, text);
}
/**
* Sends a message with level ERROR
* @param {string} origin
* @param {string} text
*/
error(origin, text) {
this._push('ERROR', origin, text);
}
}
export const socketProvider = new SocketController();
@@ -1,159 +0,0 @@
import { Server } from 'node-osc';
import { OSCSettings } from 'ontime-types';
import { PlaybackService } from '../services/PlaybackService.js';
import { messageManager } from '../classes/message-manager/MessageManager.js';
import { socketProvider } from '../classes/socket/SocketController.js';
let oscServer = null;
/**
* @description utility function to shut down osc server
*/
export const shutdownOSCServer = () => {
if (oscServer != null) oscServer.close();
};
/**
* Initialises OSC server
*/
export const initiateOSC = (config: OSCSettings) => {
oscServer = new Server(config.portIn, '0.0.0.0');
oscServer.on('error', console.error);
oscServer.on('message', function (msg) {
// message should look like /ontime/{path} {args} where
// ontime: fixed message for app
// path: command to be called
// args: extra data, only used on some API entries (delay, goto)
// split message
const [, address, path] = msg[0].split('/');
const args = msg[1];
// get first part before (ontime)
if (address !== 'ontime') {
console.error('RX', `OSC IN: Message address ${address} not recognised`, msg);
return;
}
// get second part (command)
if (!path) {
console.error('RX', 'OSC IN: No path found');
return;
}
switch (path.toLowerCase()) {
case 'onair': {
messageManager.setOnAir(true);
break;
}
case 'offair': {
messageManager.setOnAir(false);
break;
}
case 'play': {
PlaybackService.start();
break;
}
case 'start': {
try {
const eventIndex = Number(args);
if (isNaN(eventIndex)) {
socketProvider.error('RX', `OSC IN: event index not recognised ${args}`);
return;
}
PlaybackService.startByIndex(eventIndex);
} catch (error) {
console.log('Error loading event: ', error);
}
break;
}
case 'startid': {
if (!args) {
socketProvider.error('RX', `OSC IN: No ID in request`);
return;
}
PlaybackService.loadById(args);
break;
}
case 'pause': {
PlaybackService.pause();
break;
}
case 'prev': {
PlaybackService.loadPrevious();
break;
}
case 'next': {
PlaybackService.loadNext();
break;
}
case 'unload':
case 'stop': {
PlaybackService.stop();
break;
}
case 'reload': {
PlaybackService.reload();
break;
}
case 'roll': {
PlaybackService.roll();
break;
}
case 'delay': {
try {
const delayTime = Number(args);
if (isNaN(delayTime)) {
socketProvider.error('RX', `OSC IN: delay time not recognised ${args}`);
return;
}
PlaybackService.setDelay(delayTime);
} catch (error) {
console.log('Error adding delay: ', error);
}
break;
}
case 'goto':
case 'load': {
try {
const eventIndex = Number(args);
if (isNaN(eventIndex) || eventIndex <= 0) {
socketProvider.error('RX', `OSC IN: event index not recognised or out of range ${eventIndex}`);
} else {
PlaybackService.loadByIndex(eventIndex - 1);
}
} catch (error) {
socketProvider.error('RX', `OSC IN: error calling goto ${error}`);
}
break;
}
case 'gotoid':
case 'loadid': {
if (!args) {
socketProvider.error('RX', `OSC IN: event ID not recognised: ${args}}`);
return;
}
try {
PlaybackService.loadById(args.toString().toLowerCase());
} catch (error) {
socketProvider.error('RX', `OSC IN: error calling goto ${error}`);
}
break;
}
case 'get-playback': {
const playback = global.timer.state;
global.timer.sendOsc('playback', playback);
break;
}
default: {
socketProvider.warning('RX', `OSC IN: unhandled message ${path}`);
break;
}
}
});
};
@@ -0,0 +1,188 @@
import { messageService } from '../services/message-service/MessageService.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
export function dispatchFromAdapter(type: string, payload: unknown, source?: 'osc' | 'ws') {
switch (type.toLowerCase()) {
case 'test-ontime': {
return { topic: 'hello' };
}
case 'ontime-poll': {
return {
topic: 'poll',
payload: eventStore.poll(),
};
}
case 'set-onair': {
if (typeof payload !== 'undefined') {
messageService.setOnAir(Boolean(payload));
}
break;
}
case 'onair': {
messageService.setOnAir(true);
break;
}
case 'offair': {
messageService.setOnAir(false);
break;
}
case 'set-timer-message-text': {
if (typeof payload !== 'string') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setTimerText(payload);
break;
}
case 'set-timer-message-visible': {
if (typeof payload === 'undefined') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setTimerVisibility(Boolean(payload));
break;
}
case 'set-public-message-text': {
if (typeof payload !== 'string') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setPublicText(payload);
break;
}
case 'set-public-message-visible': {
if (typeof payload === 'undefined') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setPublicVisibility(Boolean(payload));
break;
}
case 'set-lower-message-text': {
if (typeof payload !== 'string') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setLowerText(payload);
break;
}
case 'set-lower-message-visible': {
if (typeof payload === 'undefined') {
throw new Error(`Unable to parse payload: ${payload}`);
}
messageService.setLowerVisibility(Boolean(payload));
break;
}
case 'start': {
PlaybackService.start();
break;
}
case 'startindex': {
const eventIndex = Number(payload);
if (isNaN(eventIndex) || eventIndex <= 0) {
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
try {
// Indexes in frontend are 1 based
PlaybackService.startByIndex(eventIndex - 1);
} catch (error) {
throw new Error(`Error loading event:: ${error}`);
}
break;
}
case 'startid': {
if (!payload) {
throw new Error(`Event ID not recognised: ${payload}`);
}
PlaybackService.startById(payload);
break;
}
case 'pause': {
PlaybackService.pause();
break;
}
case 'previous': {
PlaybackService.loadPrevious();
break;
}
case 'next': {
PlaybackService.loadNext();
break;
}
case 'unload':
case 'stop': {
PlaybackService.stop();
break;
}
case 'reload': {
PlaybackService.reload();
break;
}
case 'roll': {
PlaybackService.roll();
break;
}
case 'delay': {
const delayTime = Number(payload);
if (isNaN(delayTime)) {
throw new Error(`Delay time not recognised ${payload}`);
}
try {
PlaybackService.setDelay(delayTime);
} catch (error) {
throw new Error(`Could not add delay: ${error}`);
}
break;
}
case 'gotoindex':
case 'loadindex': {
const eventIndex = Number(payload);
if (isNaN(eventIndex) || eventIndex <= 0) {
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
try {
// Indexes in frontend are 1 based
PlaybackService.loadByIndex(eventIndex - 1);
} catch (error) {
throw new Error(`Event index not recognised or out of range ${error}`);
}
break;
}
case 'gotoid':
case 'loadid': {
if (!payload) {
throw new Error(`Event ID not recognised: ${payload}`);
}
try {
PlaybackService.loadById(payload.toString().toLowerCase());
} catch (error) {
throw new Error(`OSC IN: error calling goto ${error}`);
}
break;
}
case 'get-playback': {
const playback = eventStore.get('playback');
return { topic: 'playback', payload: playback };
}
case 'get-timer': {
const timer = eventStore.get('timer');
return { topic: 'timer', payload: timer };
}
default: {
throw new Error(`Unhandled message ${type}`);
}
}
}
@@ -1,11 +1,10 @@
// Create controller for GET request to '/playback'
// Returns ACK message
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
// Create controller for POST request to '/playback'
// Returns playback state
export const pbGet = async (req, res) => {
res.send({ playback: global.timer.state });
res.send({ playback: eventStore.get('playback') });
};
// Create controller for POST request to '/playback/start'
+2 -2
View File
@@ -2,8 +2,8 @@ import * as Sentry from '@sentry/node';
let shouldReport;
export function initSentry(environment) {
shouldReport = environment === 'production';
export function initSentry(doReport) {
shouldReport = doReport;
Sentry.init({
dsn: 'https://ceb6abdce7374857bb50b65636cbaed1@o4504288369836032.ingest.sentry.io/4504288555565056',
tracesSampleRate: 1.0,
@@ -1,11 +1,10 @@
/**
* starts loaded timer
*/
import { socketProvider } from '../classes/socket/SocketController.js';
import { Playback } from 'ontime-types';
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';
/**
* Service manages playback status of app
@@ -20,9 +19,9 @@ export class PlaybackService {
static loadEvent(event) {
let success = false;
if (!event) {
socketProvider.error('PLAYBACK', 'No event found');
logger.error('PLAYBACK', 'No event found');
} else if (event.skip) {
socketProvider.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
} else {
eventLoader.loadEvent(event);
eventTimer.load(event);
@@ -41,7 +40,7 @@ export class PlaybackService {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
@@ -56,7 +55,7 @@ export class PlaybackService {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
@@ -71,7 +70,7 @@ export class PlaybackService {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
@@ -85,7 +84,7 @@ export class PlaybackService {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
@@ -98,7 +97,7 @@ export class PlaybackService {
if (previousEvent) {
const success = PlaybackService.loadEvent(previousEvent);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
}
}
}
@@ -111,7 +110,7 @@ export class PlaybackService {
if (nextEvent) {
const success = PlaybackService.loadEvent(nextEvent);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
}
}
}
@@ -120,10 +119,10 @@ export class PlaybackService {
* Starts playback on selected event
*/
static start() {
if (eventLoader.selectedEventId) {
if (eventTimer.playback === Playback.Armed || eventTimer.playback === Playback.Pause) {
eventTimer.start();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -131,10 +130,10 @@ export class PlaybackService {
* Pauses playback on selected event
*/
static pause() {
if (eventLoader.selectedEventId) {
if (eventTimer.playback === Playback.Play) {
eventTimer.pause();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -142,11 +141,11 @@ export class PlaybackService {
* Stops timer and unloads any events
*/
static stop() {
if (eventLoader.selectedEventId || eventTimer.playback === 'roll') {
if (eventTimer.playback !== Playback.Stop) {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -154,8 +153,8 @@ export class PlaybackService {
* Reloads current event
*/
static reload() {
if (eventLoader.selectedEventId) {
this.loadById(eventLoader.selectedEventId);
if (eventTimer.loadedTimerId) {
this.loadById(eventTimer.loadedTimerId);
}
}
@@ -168,14 +167,14 @@ export class PlaybackService {
// nothing to play
if (rollTimers === null) {
socketProvider.error('SERVER', 'Roll: no events found');
logger.warning('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
const { currentEvent, nextEvent, timers } = rollTimers;
if (!currentEvent && !nextEvent) {
socketProvider.error('SERVER', 'Roll: no events found');
logger.warning('SERVER', 'Roll: no events found');
PlaybackService.stop();
return;
}
@@ -183,7 +182,7 @@ export class PlaybackService {
eventTimer.roll(currentEvent, nextEvent, timers);
const newState = eventTimer.playback;
socketProvider.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
}
}
@@ -192,10 +191,10 @@ export class PlaybackService {
* @param {number} delayTime time in minutes
*/
static setDelay(delayTime) {
if (eventLoader.selectedEventId) {
if (eventTimer.loadedTimerId) {
const delayInMs = delayTime * 1000 * 60;
eventTimer.delay(delayInMs);
socketProvider.info('PLAYBACK', `Added ${delayTime} min delay`);
logger.info('PLAYBACK', `Added ${delayTime} min delay`);
}
}
}
+21 -17
View File
@@ -5,16 +5,15 @@ import { block as blockDef, delay as delayDef, event as eventDef } from '../mode
import { MAX_EVENTS } from '../settings.js';
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
import { eventTimer } from './TimerService.js';
import { eventStore } from '../stores/EventStore.js';
/**
* Checks if a list of IDs is in the current selection
*/
const affectedLoaded = (affectedIds: string[]) => {
const now = eventLoader.selectedEventId;
const nowPublic = eventLoader.selectedPublicEventId;
const next = eventLoader.nextEventId;
const nextPublic = eventLoader.nextPublicEventId;
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) ||
@@ -28,8 +27,8 @@ const affectedLoaded = (affectedIds: string[]) => {
*/
const isNewNext = () => {
const timedEvents = EventLoader.getTimedEvents();
const now = eventLoader.selectedEventId;
const next = eventLoader.nextEventId;
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);
@@ -39,8 +38,8 @@ const isNewNext = () => {
return true;
}
// iterate through timed events and see if there are public events between nowPublic and nextPublic
const nowPublic = eventLoader.selectedPublicEventId;
const nextPublic = eventLoader.nextPublicEventId;
const nowPublic = eventLoader.loaded.selectedPublicEventId;
const nextPublic = eventLoader.loaded.nextPublicEventId;
let foundNew = false;
let isAfter = false;
@@ -67,7 +66,7 @@ const isNewNext = () => {
* Updates timer object
*/
export function updateTimer(affectedIds?: string[]) {
const runningEventId = eventLoader.selectedEventId;
const runningEventId = eventLoader.loaded.selectedEventId;
if (runningEventId === null) {
return false;
@@ -112,7 +111,7 @@ export function updateTimer(affectedIds?: string[]) {
* @param {object} eventData
* @return {unknown[]}
*/
export async function addEvent(eventData) {
export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
const numEvents = DataProvider.getRundownLength();
if (numEvents > MAX_EVENTS) {
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
@@ -145,7 +144,7 @@ export async function addEvent(eventData) {
throw new Error(error);
}
updateTimer([id]);
eventStore.broadcast();
updateChangeNumEvents();
return newEvent;
}
@@ -157,7 +156,6 @@ export async function editEvent(eventData) {
}
const newEvent = await DataProvider.updateEventById(eventId, eventData);
updateTimer([eventId]);
eventStore.broadcast();
return newEvent;
}
@@ -169,7 +167,7 @@ export async function editEvent(eventData) {
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
updateTimer([eventId]);
eventStore.broadcast();
updateChangeNumEvents();
}
/**
@@ -179,7 +177,7 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
eventStore.broadcast();
updateChangeNumEvents();
}
/**
@@ -204,7 +202,6 @@ export async function reorderEvent(eventId, from, to) {
// save rundown
await DataProvider.setRundown(rundown);
updateTimer();
return reorderedItem;
}
@@ -260,5 +257,12 @@ export async function applyDelay(eventId) {
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
eventStore.broadcast();
}
/**
* Forces update in the store
* Called when we make changes to the rundown object
*/
function updateChangeNumEvents() {
eventLoader.updateNumEvents();
}
+14 -27
View File
@@ -1,4 +1,4 @@
import { TimerLifeCycle, TimerType } from 'ontime-types';
import { Playback, TimerLifeCycle, TimerState } from 'ontime-types';
import { eventStore } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
@@ -11,27 +11,14 @@ import { clock } from './Clock.js';
export class TimerService {
private readonly _interval: NodeJS.Timer;
playback: string;
playback: Playback;
timer: TimerState;
loadedTimerId: null;
private pausedTime: number;
private pausedAt: number | null;
private secondaryTarget: number | null;
timer: {
clock: number; // realtime clock
current: number | null; // running countdown
elapsed: number | null; // elapsed time in current timer
expectedFinish: number | null;
addedTime: number; // time added by user, can be negative
startedAt: number | null;
finishedAt: number | null; // only if timer has already finished
secondaryTimer: number | null; // used for roll mode
selectedEventId: string | null;
duration: number | null;
timerType: TimerType | null;
};
/**
* @constructor
* @param {object} [timerConfig]
@@ -47,7 +34,7 @@ export class TimerService {
* @private
*/
_clear() {
this.playback = 'stop';
this.playback = Playback.Stop;
this.timer = {
clock: clock.timeNow(),
current: null,
@@ -128,7 +115,7 @@ export class TimerService {
this.loadedTimerId = timer.id;
this.timer.duration = timer.duration;
this.timer.current = timer.duration;
this.playback = 'armed';
this.playback = Playback.Armed;
this.timer.timerType = timer.timerType;
this.pausedTime = 0;
this.pausedAt = 0;
@@ -151,7 +138,7 @@ export class TimerService {
return;
}
if (this.playback === 'play') {
if (this.playback === Playback.Play) {
return;
}
@@ -166,7 +153,7 @@ export class TimerService {
this.timer.startedAt = this.timer.clock;
}
this.playback = 'play';
this.playback = Playback.Play;
this.timer.expectedFinish = getExpectedFinish(
this.timer.startedAt,
this.timer.finishedAt,
@@ -188,11 +175,11 @@ export class TimerService {
}
pause() {
if (this.playback !== 'play') {
if (this.playback !== Playback.Play) {
return;
}
this.playback = 'pause';
this.playback = Playback.Pause;
this.timer.clock = clock.timeNow();
this.pausedAt = this.timer.clock;
this._onPause();
@@ -205,7 +192,7 @@ export class TimerService {
}
stop() {
if (this.playback === 'stop') {
if (this.playback === Playback.Stop) {
return;
}
@@ -248,7 +235,7 @@ export class TimerService {
update() {
this.timer.clock = clock.timeNow();
if (this.playback === 'roll') {
if (this.playback === Playback.Roll) {
const tempCurrentTimer = {
selectedEventId: this.loadedTimerId,
current: this.timer.current,
@@ -279,11 +266,11 @@ export class TimerService {
} else {
// we only update timer if a timer has been started
if (this.timer.startedAt !== null) {
if (this.playback === 'pause') {
if (this.playback === Playback.Pause) {
this.pausedTime = this.timer.clock - this.pausedAt;
}
if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) {
if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) {
this.timer.finishedAt = this.timer.clock;
this._onFinish();
} else {
@@ -338,7 +325,7 @@ export class TimerService {
this.secondaryTarget = nextEvent.timeStart;
}
this.playback = 'roll';
this.playback = Playback.Roll;
this._onRoll();
this.update();
}
@@ -1,11 +1,13 @@
import { MessageControl } from 'ontime-types';
import { Message } from 'ontime-types';
import { eventStore } from '../../stores/EventStore.js';
let instance;
class MessageService {
messages: MessageControl;
timerMessage: Message;
publicMessage: Message;
lowerMessage: Message;
onAir: boolean;
constructor() {
@@ -16,20 +18,21 @@ class MessageService {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.messages = {
presenter: {
text: '',
visible: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
this.timerMessage = {
text: '',
visible: false,
};
this.publicMessage = {
text: '',
visible: false,
};
this.lowerMessage = {
text: '',
visible: false,
};
this.onAir = false;
}
@@ -37,8 +40,8 @@ class MessageService {
* @description sets message on stage timer screen
*/
setTimerText(payload: string) {
this.messages.presenter.text = payload;
eventStore.set('feat-messagecontrol', { messages: this.messages });
this.timerMessage.text = payload;
eventStore.set('timerMessage', this.timerMessage);
return this.getAll();
}
@@ -46,8 +49,8 @@ class MessageService {
* @description sets message visibility on stage timer screen
*/
setTimerVisibility(status: boolean) {
this.messages.presenter.visible = status;
eventStore.set('feat-messagecontrol', { messages: this.messages });
this.timerMessage.visible = status;
eventStore.set('timerMessage', this.timerMessage);
return this.getAll();
}
@@ -55,8 +58,8 @@ class MessageService {
* @description sets message on public screen
*/
setPublicText(payload: string) {
this.messages.public.text = payload;
eventStore.set('feat-messagecontrol', { messages: this.messages });
this.publicMessage.text = payload;
eventStore.set('publicMessage', this.publicMessage);
return this.getAll();
}
@@ -64,8 +67,8 @@ class MessageService {
* @description sets message visibility on public screen
*/
setPublicVisibility(status: boolean) {
this.messages.public.visible = status;
eventStore.set('feat-messagecontrol', { messages: this.messages });
this.publicMessage.visible = status;
eventStore.set('publicMessage', this.publicMessage);
return this.getAll();
}
@@ -73,8 +76,8 @@ class MessageService {
* @description sets message on lower third screen
*/
setLowerText(payload: string) {
this.messages.lower.text = payload;
eventStore.set('feat-messagecontrol', { messages: this.messages });
this.lowerMessage.text = payload;
eventStore.set('lowerMessage', this.lowerMessage);
return this.getAll();
}
@@ -82,16 +85,21 @@ class MessageService {
* @description sets message visibility on lower third screen
*/
setLowerVisibility(status: boolean) {
this.messages.lower.visible = status;
eventStore.set('feat-messagecontrol', { messages: this.messages });
this.lowerMessage.visible = status;
eventStore.set('lowerMessage', this.lowerMessage);
return this.getAll();
}
/**
* @description set state of onAir
* @description set state of onAir, toggles if parameters are offered
*/
setOnAir(status: boolean) {
this.onAir = status;
setOnAir(status?: boolean) {
if (typeof status === 'undefined') {
this.onAir = !this.onAir;
} else {
this.onAir = status;
}
eventStore.set('onAir', this.onAir);
return this.getAll();
}
@@ -100,10 +108,12 @@ class MessageService {
*/
getAll() {
return {
messages: this.messages,
timerMessage: this.timerMessage,
publicMessage: this.publicMessage,
lowerMessage: this.lowerMessage,
onAir: this.onAir,
};
}
}
export const messageManager = new MessageService();
export const messageService = new MessageService();
-23
View File
@@ -1,23 +0,0 @@
import { socketProvider } from '../classes/socket/SocketController.js';
const store = {};
/**
* A runtime store that broadcasts its payload
*/
export const eventStore = {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
socketProvider.send(key, value);
},
poll() {
return store;
},
broadcast() {
socketProvider.send(store);
socketProvider.broadcastState();
},
};
+31
View File
@@ -0,0 +1,31 @@
import { RuntimeStore } from 'ontime-types';
import { socket } from '../adapters/WebsocketAdapter.js';
const store: Partial<RuntimeStore> = {};
/**
* A runtime store that broadcasts its payload
*/
export const eventStore = {
get<T extends keyof RuntimeStore>(key: T) {
return store[key];
},
set<T extends keyof RuntimeStore>(key: T, value: RuntimeStore[T]) {
store[key] = value;
// TODO: Partial updates seems to cause issues on the client
// socket.send({
// type: `ontime-${key}`,
// payload: value,
// });
this.broadcast();
},
poll() {
return store;
},
broadcast() {
socket.send({
type: 'ontime',
payload: store,
});
},
};
-31
View File
@@ -26,37 +26,6 @@ export const isTimeString = (string) => {
return regex.test(string);
};
/**
* @description Converts milliseconds to string representing time
* @param {number} ms - time in milliseconds
* @param {boolean} showSeconds - weather to show the seconds
* @param {string} delim - character between HH MM SS
* @param {string} ifNull - what to return if value is null
* @returns {string} String representing time 00:12:02
*/
export const stringFromMillis = (ms, showSeconds = true, delim = ':', ifNull = '...') => {
if (ms == null || isNaN(ms)) return ifNull;
const isNegative = ms < 0 ? '-' : '';
const millis = Math.abs(ms);
/**
* @description ensures value is double digit
* @param value
* @return {string|*}
*/
const showWith0 = (value) => (value < 10 ? `0${value}` : value);
const hours = showWith0(Math.floor(((millis / mth) % 60) % 24));
const minutes = showWith0(Math.floor((millis / mtm) % 60));
const seconds = showWith0(Math.floor((millis / mts) % 60));
return showSeconds
? `${isNegative}${
parseInt(hours, 10) ? `${hours}${delim}` : `00${delim}`
}${minutes}${delim}${seconds}`
: `${isNegative}${parseInt(hours, 10) ? `${hours}` : '00'}${delim}${minutes}`;
};
/**
* @description Converts an excel date to milliseconds
* @argument {string} date - excel string date