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
+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,109 +0,0 @@
import { MessageControl } from 'ontime-types';
import { eventStore } from '../../stores/EventStore.js';
let instance;
class MessageService {
messages: MessageControl;
onAir: boolean;
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.messages = {
presenter: {
text: '',
visible: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
};
this.onAir = false;
}
/**
* @description sets message on stage timer screen
*/
setTimerText(payload: string) {
this.messages.presenter.text = payload;
eventStore.set('feat-messagecontrol', { messages: this.messages });
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
*/
setTimerVisibility(status: boolean) {
this.messages.presenter.visible = status;
eventStore.set('feat-messagecontrol', { messages: this.messages });
return this.getAll();
}
/**
* @description sets message on public screen
*/
setPublicText(payload: string) {
this.messages.public.text = payload;
eventStore.set('feat-messagecontrol', { messages: this.messages });
return this.getAll();
}
/**
* @description sets message visibility on public screen
*/
setPublicVisibility(status: boolean) {
this.messages.public.visible = status;
eventStore.set('feat-messagecontrol', { messages: this.messages });
return this.getAll();
}
/**
* @description sets message on lower third screen
*/
setLowerText(payload: string) {
this.messages.lower.text = payload;
eventStore.set('feat-messagecontrol', { messages: this.messages });
return this.getAll();
}
/**
* @description sets message visibility on lower third screen
*/
setLowerVisibility(status: boolean) {
this.messages.lower.visible = status;
eventStore.set('feat-messagecontrol', { messages: this.messages });
return this.getAll();
}
/**
* @description set state of onAir
*/
setOnAir(status: boolean) {
this.onAir = status;
return this.getAll();
}
/**
* @description Returns feature data
*/
getAll() {
return {
messages: this.messages,
onAir: this.onAir,
};
}
}
export const messageManager = new MessageService();
@@ -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();