V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
Carlos Valente
2023-02-14 22:02:15 +01:00
committed by GitHub
parent 3918758d32
commit de9a7a87fd
439 changed files with 11381 additions and 14294 deletions
@@ -0,0 +1,198 @@
/**
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { data, db } from '../../modules/loadDb.js';
export class DataProvider {
static getData() {
return data;
}
static async setEventData(newData) {
data.event = { ...data.event, ...newData };
await this.persist();
return data.event;
}
static getEventData() {
return data.event;
}
static async setRundown(newData) {
data.rundown = [...newData];
await this.persist();
}
static getEventById(eventId) {
return data.rundown.find((e) => e.id === eventId);
}
static async updateEventById(eventId, newData) {
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
const persistedEvent = data.rundown[eventIndex];
const newEvent = { ...persistedEvent, ...newData };
newEvent.revision++;
data.rundown[eventIndex] = newEvent;
await this.persist();
return data.rundown[eventIndex];
}
static async deleteEvent(eventId) {
data.rundown = Array.from(data.rundown).filter((e) => e.id !== eventId);
await this.persist();
}
static getRundownLength() {
return data.rundown.length;
}
static async clearRundown() {
data.rundown = [];
await db.write();
}
/**
* Insets an event after a given index
* @param entry
* @param index
* @return {Promise<void>}
*/
static async insertEventAt(entry, index) {
// get events
const events = DataProvider.getRundown();
const count = events.length;
const order = entry.order;
// Remove order field from object
delete entry.order;
// Insert at beginning
if (order === 0) {
events.unshift(entry);
}
// insert at end
else if (order >= count) {
events.push(entry);
}
// insert in the middle
else {
events.splice(index, 0, entry);
}
// save events
await DataProvider.setRundown(events);
}
/**
* @description Inserts an entry after an element with given ID
* @param entry
* @param id
* @return {Promise<void>}
*/
static async insertEventAfterId(entry, id) {
const index = [...data.rundown].findIndex((event) => event.id === id);
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars -- we are just getting rid of after parameter
const { after, ...sanitisedEvent } = entry;
await DataProvider.insertEventAt(sanitisedEvent, index + 1);
}
static getSettings() {
return data.settings;
}
static async setSettings(newData) {
data.settings = { ...newData };
await this.persist();
}
static getOsc() {
return data.osc;
}
static getAliases() {
return data.aliases;
}
static async setAliases(newData) {
data.aliases = newData;
await this.persist();
}
static getUserFields() {
return { ...data.userFields };
}
static getViews() {
return { ...data.views };
}
static async setViews(newData) {
data.views = { ...newData };
await this.persist();
}
static async setUserFields(newData) {
data.userFields = { ...newData };
await this.persist();
}
static async setOsc(newData) {
data.osc = { ...newData };
await this.persist();
}
static getRundown() {
return [...data.rundown];
}
static async persist() {
await db.write();
}
static async mergeIntoData(newData) {
const mergedData = DataProvider.safeMerge(data, newData);
data.event = mergedData.event;
data.settings = mergedData.settings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.aliases = mergedData.aliases;
data.userFields = mergedData.userFields;
data.rundown = mergedData.rundown;
await this.persist();
}
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
static safeMerge(existing, newData) {
const mergedData = { ...existing };
if (typeof newData?.rundown !== 'undefined') {
mergedData.rundown = newData.rundown;
}
if (typeof newData?.event !== 'undefined') {
mergedData.event = { ...newData.event };
}
if (typeof newData?.settings !== 'undefined') {
mergedData.settings = { ...newData.settings };
}
if (typeof newData?.osc !== 'undefined') {
mergedData.osc = { ...newData.osc };
}
if (typeof newData?.http !== 'undefined') {
mergedData.http = { ...newData.http };
}
if (typeof newData?.aliases !== 'undefined') {
mergedData.aliases = [...newData.aliases];
}
if (typeof newData?.userFields !== 'undefined') {
mergedData.userFields = { ...existing.userFields, ...newData.userFields };
}
return mergedData;
}
}
@@ -0,0 +1,401 @@
import { DataProvider } from '../data-provider/DataProvider.js';
import { getRollTimers } from '../../services/rollUtils.js';
let instance;
/**
* Manages business logic around loading events
*/
export class EventLoader {
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.reset();
this.loadedEvent = null;
}
/**
* returns all events that contain time data
* @return {array}
*/
static getTimedEvents() {
// return mockLoaderData.filter((event) => event.type === 'event');
return DataProvider.getRundown().filter((event) => event.type === 'event');
}
/**
* returns all events that can be loaded
* @return {array}
*/
static getPlayableEvents() {
// return mockLoaderData.filter((event) => event.type === 'event' && !event.skip);
return DataProvider.getRundown().filter((event) => event.type === 'event' && !event.skip);
}
/**
* returns number of events
* @return {number}
*/
static getNumEvents() {
return EventLoader.getTimedEvents().length;
}
/**
* returns an event given its index
* @param {number} eventIndex
* @return {object | undefined}
*/
static getEventAtIndex(eventIndex) {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents?.[eventIndex];
}
/**
* returns an event given its index
* @param {number} eventIndex
* @return {object | undefined}
*/
static getPlayableAtIndex(eventIndex) {
const timedEvents = EventLoader.getPlayableEvents();
return timedEvents?.[eventIndex];
}
/**
* returns an event given its id
* @param {string} eventId
* @return {object | undefined}
*/
static getEventWithId(eventId) {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
}
/**
* loads an event given its id
* @param {string} eventId
* @returns {{loadedEvent: null, selectedEventId: null, nextEventId: null, selectedPublicEventId: null, nextPublicEventId: null, numEvents: null, titles: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}, titlesPublic: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}, selectedEventIndex: null}}
*/
loadById(eventId) {
const event = EventLoader.getEventWithId(eventId);
return this.loadEvent(event);
}
/**
* loads an event given its index
* @param {number} eventIndex
* @returns {{loadedEvent: null, selectedEventId: null, nextEventId: null, selectedPublicEventId: null, nextPublicEventId: null, numEvents: null, titles: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}, titlesPublic: {presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}, selectedEventIndex: null}}
*/
loadByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
return this.loadEvent(event);
}
/**
* finds the previous event
* @return {object | undefined}
*/
findPrevious() {
const timedEvents = EventLoader.getPlayableEvents();
if (timedEvents === null || !timedEvents.length || this.selectedEventIndex === 0) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
return timedEvents[0];
}
const newIndex = this.selectedEventIndex - 1;
return timedEvents?.[newIndex];
}
/**
* finds the next event
* @return {object | undefined}
*/
findNext() {
const timedEvents = EventLoader.getPlayableEvents();
if (
timedEvents === null ||
!timedEvents.length ||
this.selectedEventIndex === this.numEvents - 1
) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
return timedEvents[0];
}
const newIndex = this.selectedEventIndex + 1;
return timedEvents?.[newIndex];
}
/**
* finds next event within Roll context
* @param {number} timeNow - current time in ms
*/
findRoll(timeNow) {
const timedEvents = EventLoader.getPlayableEvents();
if (!timedEvents.length) {
return null;
}
const {
nowIndex,
timers,
timeToNext,
nextEvent,
nextPublicEvent,
currentEvent,
currentPublicEvent,
} = getRollTimers(timedEvents, timeNow);
this.loadedEvent = currentEvent;
this.selectedEventIndex = nowIndex;
this.selectedEventId = currentEvent?.id || null;
this.numEvents = timedEvents.length;
// titles
this._loadThisTitles(currentEvent, 'now-private');
this._loadThisTitles(currentPublicEvent, 'now-public');
this._loadThisTitles(nextEvent, 'next-private');
this._loadThisTitles(nextPublicEvent, 'next-public');
return { currentEvent, nextEvent, timeToNext, timers };
}
/**
* returns data for currently loaded event
* @returns {{loadedEvent: null, selectedEventId: (null|*), nextEventId: (null|*), selectedPublicEventId: (null|*), nextPublicEventId: (null|*), numEvents: (null|number|*), titles: (*|{presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null, noteNow: null, noteNext: null}), titlesPublic: (*|{presenterNext: null, titleNow: null, subtitleNow: null, titleNext: null, subtitleNext: null, presenterNow: null}), selectedEventIndex: (null|number|*)}}
*/
getLoaded() {
return {
loadedEvent: this.loadedEvent,
selectedEventIndex: this.selectedEventIndex,
selectedEventId: this.selectedEventId,
selectedPublicEventId: this.selectedPublicEventId,
nextEventId: this.nextEventId,
nextPublicEventId: this.nextPublicEventId,
numEvents: this.numEvents,
titles: this.titles,
titlesPublic: this.titlesPublic,
};
}
reset() {
this.loadedEvent = null;
this.selectedEventIndex = null;
this.selectedEventId = null;
this.selectedPublicEventId = null;
this.nextEventId = null;
this.nextPublicEventId = null;
this.numEvents = null;
this.titles = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
};
this.titlesPublic = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
};
}
/**
* loads an event given its id
* @param {object} event
*/
loadEvent(event) {
if (typeof event === 'undefined') {
return null;
}
const timedEvents = EventLoader.getPlayableEvents();
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
const playableEvents = EventLoader.getPlayableEvents();
// we know some stuff now
this.loadedEvent = event;
this.selectedEventIndex = eventIndex;
this.selectedEventId = event.id;
this.numEvents = timedEvents.length;
// this.nextEventId = playableEvents[eventIndex + 1].id;
this._loadTitlesNow(event, playableEvents);
this._loadTitlesNext(playableEvents);
return this.getLoaded();
}
/**
* @description loads given title (now)
* @private
* @param {object} event
* @param {array} rundown
*/
_loadTitlesNow(event, rundown) {
// private title is always current
// check if current is also public
if (event.isPublic) {
this._loadThisTitles(event, 'now');
} else {
this._loadThisTitles(event, 'now-private');
// assume there is no public event
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.selectedPublicEventId = null;
// if there is nothing before, return
if (this.selectedEventIndex === 0) return;
// iterate backwards to find it
for (let i = this.selectedEventIndex; i >= 0; i--) {
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'now-public');
break;
}
}
}
}
/**
* @description look for next titles to load
* @private
*/
_loadTitlesNext(rundown) {
// Todo: is there a scenario where this gets called without an event?
// maybe there is nothing to load
if (this.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.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.nextPublicEventId = null;
const numEvents = rundown.length;
if (this.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextPrivate = false;
for (let i = this.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextPrivate) {
this._loadThisTitles(rundown[i], 'next-private');
nextPrivate = true;
}
// if event is public
if (rundown[i].isPublic) {
this._loadThisTitles(rundown[i], 'next-public');
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextPrivate) break;
}
}
}
/**
* @description loads given title
* @param event
* @param type
* @private
*/
_loadThisTitles(event, type) {
if (!event) {
return;
}
switch (type) {
// now, load to both public and private
case 'now':
// public
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.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;
break;
case 'now-public':
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.selectedPublicEventId = event.id;
break;
case 'now-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;
break;
// next, load to both public and private
case 'next':
// public
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.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;
break;
case 'next-public':
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.nextPublicEventId = event.id;
break;
case 'next-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;
break;
default:
throw new Error(`Unhandled title type: ${type}`);
}
}
}
export const eventLoader = new EventLoader();
@@ -0,0 +1,53 @@
import * as http from 'http';
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HTTPIntegration {
constructor() {
// nothing to do here
}
/**
* @description Initializes oscClient
* @param {object} httpConfig - Http configurations options
*/
init(httpConfig) {}
/**
* @description Sends http get request from predefined messages
* @param {string} path - complete http path
*/
async send(path) {
if (path == null) {
console.log('HTTP ERROR: Message undefined');
return;
}
const options = new URL(path);
let str = '';
const req = http.request(options, (res) => {
console.log(`statusCode: ${res.statusCode}`);
res.on('data', function (chunk) {
str += chunk;
});
res.on('end', function () {
console.log(str);
});
});
req.on('error', (error) => {
console.error(error);
});
req.end();
}
shutdown() {
/* Nothing to shutdown */
}
}
+169
View File
@@ -0,0 +1,169 @@
import { Client, Message } from 'node-osc';
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OSCIntegration {
constructor() {
// OSC Client
this.ADDRESS = '/ontime';
this.oscClient = null;
}
/**
* @description Returns list of implemented messages
* @returns {object} implemented messages
*/
get implemented() {
return {
play: 'play',
pause: 'pause',
stop: 'stop',
previous: 'prev',
next: 'next',
reload: 'reload',
finished: 'finished',
time: 'time',
overtime: 'overtime',
title: 'title',
eventNumber: 'eventNumber',
presenter: 'presenter',
};
}
/**
* @description Initializes oscClient
* @param {object} oscConfig - oscClient configuration options
* @param {string} oscConfig.ip - oscClient object
* @param {number} oscConfig.port - OSC Destination Port
*/
init(oscConfig) {
const { ip, port } = oscConfig;
const validateType = typeof ip !== 'string' || typeof port !== 'number';
const validateNull = ip == null || port == null;
if (validateType || validateNull) {
return {
success: false,
message: `Config options incorrect`,
};
}
try {
this.oscClient = new Client(ip, port);
return {
success: true,
message: `Initialised OSC Client at ${ip}:${port}`,
};
} catch (error) {
this.oscClient = null;
return {
success: false,
message: `Failed initialising OSC Client: ${error}`,
};
}
}
/**
* @description Sends osc from predefined messages
* @param {string} messageType - message to be sent
* @param {string} [payload] - optional payload required in some message types
*/
async send(messageType, payload) {
const reply = {
success: true,
message: 'OSC Message sent',
};
if (this.oscClient == null) {
reply.success = false;
reply.message = 'Client not initialised';
return reply;
}
if (messageType == null) {
reply.success = false;
reply.message = 'Message undefined';
return reply;
}
// only specify special cases
switch (payload) {
case 'overtime': {
// Whether timer is negative
this.oscClient.send(`${this.ADDRESS}/overtime`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
break;
}
case 'title': {
if (payload != null && payload !== '') {
// Send Title of current event
this.oscClient.send(`${this.ADDRESS}/title`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
case 'eventNumber': {
if (payload != null && payload !== '') {
// Send event number of current event
this.oscClient.send(`${this.ADDRESS}/eventNumber`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
case 'presenter': {
if (payload != null && payload !== '') {
// Send timer data on current event
this.oscClient.send(`${this.ADDRESS}/presenter`, payload, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
} else {
reply.success = false;
reply.message = 'Missing message data';
}
break;
}
default: {
// catch all for messages, allows to add new messages
// but should be used with the integrations definition
const message = new Message(`${this.ADDRESS}/${messageType}`);
if (payload != null) message.append(payload);
this.oscClient.send(message, (err) => {
if (err) {
reply.success = false;
reply.message = err;
}
});
break;
}
}
return reply;
}
shutdown() {
// Shutdown client object
this.oscClient.close();
this.oscClient = null;
}
}
@@ -0,0 +1,163 @@
import { OSCIntegration } from '../Osc';
import { Server } from 'node-osc';
test('Class initialises correctly', () => {
const osc = new OSCIntegration();
expect(osc.ADDRESS).toBe('/ontime');
expect(osc.oscClient).toBe(null);
// defined objects
expect(osc.implemented.play).toBeDefined();
expect(osc.implemented.pause).toBeDefined();
expect(osc.implemented.stop).toBeDefined();
expect(osc.implemented.previous).toBeDefined();
expect(osc.implemented.next).toBeDefined();
expect(osc.implemented.reload).toBeDefined();
expect(osc.implemented.finished).toBeDefined();
expect(osc.implemented.time).toBeDefined();
expect(osc.implemented.overtime).toBeDefined();
expect(osc.implemented.title).toBeDefined();
expect(osc.implemented.eventNumber).toBeDefined();
expect(osc.implemented.presenter).toBeDefined();
// initialise client succeeds
const { ip, port } = { ip: '127.0.0.1', port: 12345 };
const init = osc.init({ ip, port });
expect(init.message).toBe(`Initialised OSC Client at ${ip}:${port}`);
expect(init.success).toBe(true);
expect(osc.oscClient).not.toBe(null);
// object shutdown as expected
osc.shutdown();
expect(osc.oscClient).toBe(null);
});
describe('OSC fails to initialise when incorrect data is given', () => {
it('IP of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 123, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('IP is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: null, port: 8888 });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('Port of wrong type', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: 'test' });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
it('Port is null', () => {
const osc = new OSCIntegration();
const init = osc.init({ ip: 'localhost', port: null });
expect(init.message).toBe('Config options incorrect');
expect(init.success).toBe(false);
expect(osc.oscClient).toBe(null);
});
});
test('Test messages sending', async () => {
const testPort = 9999;
const testIP = 'localhost';
const testPayload = 'test';
const osc = new OSCIntegration();
const messages = [];
// prepare dummy server to receive messages
const oscServer = new Server(testPort, testIP);
oscServer.on('message', (m) => {
messages.push({ yay: m });
});
// try and send a message before initialising
const test = await osc.send('test');
expect(test.success).toBe(false);
expect(test.message).toBe('Client not initialised');
// initialise osc
osc.init({ ip: testIP, port: testPort });
// try and send unrecognised message
const test2 = await osc.send('test');
expect(test2.success).toBe(true);
// send play message
const playAddress = osc.implemented.play;
const playSent = await osc.send(playAddress);
expect(playSent.success).toBe(true);
// send pause message
const pauseAddress = osc.implemented.pause;
const pauseSent = await osc.send(pauseAddress);
expect(pauseSent.success).toBe(true);
// send stop message
const stopAddress = osc.implemented.stop;
const stopSent = await osc.send(stopAddress);
expect(stopSent.success).toBe(true);
// send previous message
const previousAddress = osc.implemented.previous;
const previousSent = await osc.send(previousAddress);
expect(previousSent.success).toBe(true);
// send next message
const nextAddress = osc.implemented.next;
const nextSent = await osc.send(nextAddress);
expect(nextSent.success).toBe(true);
// send reload message
const reloadAddress = osc.implemented.reload;
const reloadSent = await osc.send(reloadAddress);
expect(reloadSent.success).toBe(true);
// send finished message
const finishedAddress = osc.implemented.finished;
const finishedSent = await osc.send(finishedAddress);
expect(finishedSent.success).toBe(true);
// send time message
const timeAddress = osc.implemented.time;
const timeSent = await osc.send(timeAddress);
expect(timeSent.success).toBe(true);
// send overtime message
const overtimeAddress = osc.implemented.overtime;
const overtimeSent = await osc.send(overtimeAddress, testPayload);
expect(overtimeSent.success).toBe(true);
// send title message
const titleAddress = osc.implemented.title;
const titleSent = await osc.send(titleAddress, testPayload);
expect(titleSent.success).toBe(true);
// send eventNumber message
const eventNumberAddress = osc.implemented.eventNumber;
const eventNumberSent = await osc.send(eventNumberAddress, testPayload);
expect(eventNumberSent.success).toBe(true);
// send timer message
const presenterAddress = osc.implemented.presenter;
const presenterSent = await osc.send(presenterAddress, testPayload);
expect(presenterSent.success).toBe(true);
// cleanup
await osc.shutdown();
await oscServer.close();
// see messagesObject
// expect(messages.length).toBe(5);
});
@@ -0,0 +1,104 @@
let instance;
class MessageService {
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.socket = null;
this.presenter = {
text: '',
visible: false,
};
this.public = {
text: '',
visible: false,
};
this.lower = {
text: '',
visible: false,
};
this.onAir = false;
}
/**
* @description sets message on stage timer screen
* @param payload {string}
*/
setTimerText(payload) {
this.presenter.text = payload;
return this.getAll();
}
/**
* @description sets message visibility on stage timer screen
* @param status {boolean}
*/
setTimerVisibility(status) {
this.presenter.visible = status;
return this.getAll();
}
/**
* @description sets message on public screen
* @param payload {string}
*/
setPublicText(payload) {
this.public.text = payload;
return this.getAll();
}
/**
* @description sets message visibility on public screen
* @param status {boolean}
*/
setPublicVisibility(status) {
this.public.visible = status;
return this.getAll();
}
/**
* @description sets message on lower third screen
* @param payload {string}
*/
setLowerText(payload) {
this.lower.text = payload;
return this.getAll();
}
/**
* @description sets message visibility on lower third screen
* @param status {boolean}
*/
setLowerVisibility(status) {
this.lower.visible = status;
return this.getAll();
}
/**
* @description set state of onAir
* @param status {boolean}
*/
setOnAir(status) {
this.onAir = status;
return this.getAll();
}
/**
* @description Returns feature data
*/
getAll() {
return {
presenter: this.presenter,
public: this.public,
lower: this.lower,
onAir: this.onAir,
};
}
}
export const messageManager = new MessageService();
@@ -0,0 +1,464 @@
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 { ADDRESS_MESSAGE_CONTROL } from './socketConfig.js';
import { eventTimer, TimerService } from '../../services/TimerService.js';
import { EventLoader, eventLoader } from '../event-loader/EventLoader.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');
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
} catch (error) {
this.error('RX', `Failed to parse message ${data} : ${error}`);
}
}
this.send('onAir', messageManager.onAir);
});
// Presenter message
socket.on('set-timer-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
const featureData = messageManager.setTimerText(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
socket.on('set-timer-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
const featureData = messageManager.setTimerVisibility(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
/*******************************************/
// Public message
socket.on('set-public-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
const featureData = messageManager.setPublicText(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
socket.on('set-public-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
const featureData = messageManager.setPublicVisibility(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
/*******************************************/
// Lower third message
socket.on('set-lower-message-text', (data) => {
if (typeof data !== 'string') {
return;
}
const featureData = messageManager.setLowerText(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
socket.on('set-lower-message-visible', (data) => {
if (typeof data !== 'boolean') {
return;
}
const featureData = messageManager.setLowerVisibility(data);
this.socket.emit(ADDRESS_MESSAGE_CONTROL, featureData);
});
/* 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-ontime-timer', () => {
this.broadcastTimer();
});
});
}
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(TimerService.getCurrentTime() || 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(ADDRESS_MESSAGE_CONTROL, 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);
}
/**
* Broadcast Timer feature
*/
broadcastTimer() {
const featureData = eventTimer.timer;
this.send('ontime-timer', featureData);
}
broadcastState() {
this.broadcastFeatureRundown();
this.broadcastFeatureMessageControl();
this.broadcastFeaturePlaybackControl();
this.broadcastFeatureInfo();
this.broadcastFeatureCuesheet();
this.broadcastTimer();
}
/**
* 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();
@@ -0,0 +1 @@
export const ADDRESS_MESSAGE_CONTROL = 'feat-messagecontrol';