refactor: v2 event loader (#260)

* refactor: extract jest config

* refactor: rename params in reoder endpoint

* refactor: avoid app exports

* refactor: small code smells

* refactor: run development server

* refactor: extract rundown service and event loader logic

* refactor: rename events > rundown

* refactor: code style

* fix: handle external change of title

* refactor: cleanup dictionary

* refactor: rename MessageService

* refactor: use eventID for operations

* refactor: migrate OSC controller to service

* refactor: migrate Socket controller to service

* refactor: migrate HTTP controller to service

* refactor: extract logic into discrete services

* refactor: remove rundown from event timer

* refactor: remove duplicate

* refactor: remove unused

* chore: update tests
This commit is contained in:
Carlos Valente
2022-11-22 21:25:55 +01:00
committed by GitHub
parent c1f87736eb
commit ff5735fe36
32 changed files with 1236 additions and 1186 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useContext } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { RUNDOWN_TABLE_KEY,RUNDOWN_TABLE } from '../api/apiConstants';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import {
requestApplyDelay,
requestDelete,
@@ -106,7 +106,7 @@ export const useEventAction = () => {
emitError(`Error updating event: ${error.message}`);
}
},
[_updateEventMutation, emitError]
[_updateEventMutation, emitError],
);
/**
@@ -272,7 +272,7 @@ export const useEventAction = () => {
async (eventId, from, to) => {
try {
const reorderObject = {
index: eventId,
eventId: eventId,
from: from,
to: to,
};
+17 -17
View File
@@ -193,51 +193,51 @@ export default function Rundown(props) {
<Droppable droppableId='eventlist'>
{(provided) => (
<div className={style.list} {...provided.droppableProps} ref={provided.innerRef}>
{entries.map((e, index) => {
{entries.map((entry, index) => {
if (index === 0) {
cumulativeDelay = 0;
eventIndex = -1;
}
if (e.type === 'delay' && e.duration != null) {
cumulativeDelay += e.duration;
} else if (e.type === 'block') {
if (entry.type === 'delay' && entry.duration != null) {
cumulativeDelay += entry.duration;
} else if (entry.type === 'block') {
cumulativeDelay = 0;
} else if (e.type === 'event') {
} else if (entry.type === 'event') {
eventIndex++;
previousEnd = thisEnd;
thisEnd = e.timeEnd;
previousEventId = e.id;
thisEnd = entry.timeEnd;
previousEventId = entry.id;
}
const isLast = index === entries.length - 1;
return (
<div
key={e.id}
key={entry.id}
className={`${style.bgElement}
${e.type === 'event' && cumulativeDelay !== 0 ? style.delayed : ''}`}
${entry.type === 'event' && cumulativeDelay !== 0 ? style.delayed : ''}`}
>
<div
ref={cursor === index ? cursorRef : undefined}
className={cursor === index ? style.cursor : ''}
>
<RundownEntry
type={e.type}
type={entry.type}
index={index}
eventIndex={eventIndex}
data={e}
selected={selectedId === e.id}
next={nextId === e.id}
data={entry}
selected={selectedId === entry.id}
next={nextId === entry.id}
delay={cumulativeDelay}
previousEnd={previousEnd}
playback={selectedId === e.id ? data.playback : undefined}
playback={selectedId === entry.id ? data.playback : undefined}
/>
</div>
{((showQuickEntry && index === cursor) || isLast) && (
<QuickAddBlock
showKbd={index === cursor}
previousId={e.id}
previousId={entry.id}
previousEventId={previousEventId}
disableAddDelay={e.type === 'delay'}
disableAddBlock={e.type === 'block'}
disableAddDelay={entry.type === 'delay'}
disableAddBlock={entry.type === 'block'}
/>
)}
</div>
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { Editable, EditableInput, EditablePreview, Tooltip } from '@chakra-ui/react';
import { FiUsers } from '@react-icons/all-files/fi/FiUsers';
@@ -85,6 +85,11 @@ export default function EventBlock(props: EventBlockProps) {
const binderColours = colour && getAccessibleColour(colour);
const hasDelay = delay !== 0 && delay !== null;
// Todo: could I re-render the item without causing a state change here?
useEffect(() => {
setBlockTitle(title);
}, [title]);
const handleTitle = useCallback(
(text: string) => {
if (text === title) {
@@ -107,7 +112,7 @@ export default function EventBlock(props: EventBlockProps) {
if (!skip && eventIsPlaying) {
playBtnStyles._hover = { bg: '#c05621' };
} else if (!skip && !eventIsPlaying) {
playBtnStyles._hover = { };
playBtnStyles._hover = {};
}
return (
+8 -8
View File
@@ -14,7 +14,7 @@ import { makeCSV, makeTable } from './utils';
import style from './Table.module.scss';
export default function TableWrapper() {
const { data: events } = useRundown();
const { data: rundown } = useRundown();
const { data: userFields } = useUserFields();
const { data: featureData } = useCuesheet();
@@ -32,7 +32,7 @@ export default function TableWrapper() {
}
// check if value is the same
const event = events[rowIndex];
const event = rundown[rowIndex];
if (event == null) {
return;
}
@@ -59,15 +59,15 @@ export default function TableWrapper() {
} catch (error) {
console.error(error);
}
}, [mutation, events]);
}, [mutation, rundown]);
const exportHandler = useCallback(
(headerData) => {
if (!headerData || !events || !userFields) {
if (!headerData || !rundown || !userFields) {
return;
}
const sheetData = makeTable(headerData, events, userFields);
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
@@ -76,10 +76,10 @@ export default function TableWrapper() {
document.body.appendChild(link);
link.click();
},
[events, userFields]
[rundown, userFields]
);
if (typeof events === 'undefined' || typeof userFields === 'undefined') {
if (typeof rundown === 'undefined' || typeof userFields === 'undefined') {
return <span>loading...</span>;
}
return (
@@ -89,7 +89,7 @@ export default function TableWrapper() {
>
<TableHeader handleCSVExport={exportHandler} featureData={featureData} />
<OntimeTable
tableData={events}
tableData={rundown}
userFields={userFields}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
+8
View File
@@ -0,0 +1,8 @@
const config = {
verbose: true,
testEnvironment: 'node',
rootDir: 'src',
transform: {},
};
module.exports = config;
+3
View File
@@ -25,6 +25,9 @@ const nodePath = isProduction
(async () => {
try {
const dbLoader = await import('./src/modules/loadDb.js');
await dbLoader.promise;
const { startServer, startOSCServer } = await import(nodePath);
// Start express server
loaded = await startServer();
+1 -5
View File
@@ -32,7 +32,7 @@
"prep": "yarn clean && yarn setdb",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"start": "NODE_ENV=development electron .",
"start:server": "nodemon --experimental-modules --es-module-specifier-resolution=node src/run.js",
"start:server": "NODE_ENV=development nodemon --experimental-modules --es-module-specifier-resolution=node src/run.js",
"e2e": "set IS_TEST=true && playwright test",
"pack": "electron-builder --dir",
"dist": "electron-builder",
@@ -41,10 +41,6 @@
"dist-linux": "electron-builder --publish=never --x64 --linux",
"dist-all": "electron-builder -mw"
},
"jest": {
"testEnvironment": "node",
"rootDir": "src"
},
"build": {
"productName": "ontime",
"appId": "no.lightdev.ontime",
+11 -16
View File
@@ -6,8 +6,7 @@ import { config } from './config/config.js';
// import dependencies
import { dirname, join, resolve } from 'path';
// init database
import loadDb from './modules/loadDb.js';
// dependencies
import express from 'express';
import http from 'http';
@@ -22,6 +21,7 @@ import { router as playbackRouter } from './routes/playbackRouter.js';
// Global Objects
import { EventTimer } from './classes/timer/EventTimer.js';
import { socketProvider } from './classes/socket/SocketController.js';
// Start OSC server
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
import { fileURLToPath } from 'url';
@@ -33,7 +33,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const isTest = process.env.IS_TEST;
export const { db, data } = await loadDb(__dirname);
console.log(`Starting ontime version ${process.env.npm_package_version}`);
// import socket provider
@@ -74,14 +73,12 @@ const resolvedPath = () => {
app.use(express.static(join(__dirname, resolvedPath(), 'client/build')));
app.get('*', (req, res) => {
res.sendFile(
resolve(__dirname, resolvedPath(), 'client', 'build', 'index.html'),
);
res.sendFile(resolve(__dirname, resolvedPath(), 'client', 'build', 'index.html'));
});
// Implement route for errors
app.use((err, req, res, next) => {
res.status(500).send(err.stack);
// Implement catch all
app.use((error, response, _next) => {
response.status(400).send('Unhandled request');
});
/*************** START SERVICES ***************/
@@ -94,12 +91,12 @@ app.use((err, req, res, next) => {
*
*/
const { osc, settings } = DataProvider.getData();
const { osc } = DataProvider.getData();
const oscIP = osc?.targetIP || config.osc.targetIP;
const oscOutPort = osc?.portOut || config.osc.portOut;
const oscInPort = osc?.port || config.osc.port;
const oscInEnabled = osc?.enabled !== undefined ? osc.enabled : config.osc.inputEnabled;
const serverPort = settings.serverPort || config.server.port;
const serverPort = 4001; // hardcoded for now
/**
* @description starts OSC server
@@ -132,12 +129,11 @@ const server = http.createServer(app);
* @return {Promise<string>}
*/
export const startServer = async (overrideConfig = null) => {
const port = 4001; // port hardcoded
const { rundown, http } = DataProvider.getData();
const { http } = DataProvider.getData();
// Start server
const returnMessage = `Ontime is listening on port ${port}`;
server.listen(port, '0.0.0.0');
const returnMessage = `Ontime is listening on port ${serverPort}`;
server.listen(serverPort, '0.0.0.0');
// init socket controller
await socket.initServer(server);
@@ -151,7 +147,6 @@ export const startServer = async (overrideConfig = null) => {
// init timer
global.timer = new EventTimer(socket, config.timer, oscConfig, http);
global.timer.setupWithEventList(rundown.filter((entry) => entry.type === 'event'));
socket.info('SERVER', returnMessage);
socket.startListener();
@@ -2,7 +2,7 @@
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { data, db } from '../../app.js';
import { db, data } from '../../modules/loadDb.js';
export class DataProvider {
static getData() {
@@ -96,7 +96,7 @@ export class DataProvider {
static async insertEventAfterId(entry, id) {
const index = [...data.rundown].findIndex((event) => event.id === id);
// eslint-disable-next-line no-unused-vars
const { _after, ...sanitisedEvent } = entry;
const { after, ...sanitisedEvent } = entry;
await DataProvider.insertEventAt(sanitisedEvent, index + 1);
}
@@ -0,0 +1,369 @@
import { DataProvider } from '../data-provider/DataProvider.js';
import { getSelectionByRoll } from '../timer/classUtils.js';
import { Timer } from '../timer/Timer.js';
let instance;
/**
* Manages business logic around loading events
*/
export class EventLoader {
constructor() {
if (instance) {
throw new Error('There can be only one');
}
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, loadedEventId: *, 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, loadedEventId: *, 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 ID of the previous event
* @returns {{id: string}|null}
*/
findPrevious() {
const timedEvents = EventLoader.getPlayableEvents();
if (timedEvents === null || this.selectedEventIndex === 0) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
return { id: timedEvents[0].id };
} else {
const newIndex = this.selectedEventIndex - 1;
return { id: timedEvents?.[newIndex].id };
}
}
/**
* finds the ID of the next event
* @returns {{id: string}|null}
*/
findNext() {
const timedEvents = EventLoader.getPlayableEvents();
if (timedEvents === null || this.selectedEventIndex === this.numEvents - 1) {
return null;
}
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
return { id: timedEvents[0].id };
} else {
const newIndex = this.selectedEventIndex + 1;
return { id: timedEvents?.[newIndex].id };
}
}
/**
* finds next event within Roll context
* @returns {{nowIndex: null, timers: null, nowId: null, publicNextIndex: null, nextIndex: null, timeToNext: null, publicIndex: null}|{nowIndex: null, timers: null, nowId: null, publicNextIndex: null, nextIndex: null, timeToNext: null, publicIndex: null}}
*/
findRoll() {
const timedEvents = EventLoader.getPlayableEvents();
const millisNow = Timer.getCurrentTime();
return getSelectionByRoll(timedEvents, millisNow);
}
/**
* returns data for currently loaded event
* @returns {{loadedEvent: null, selectedEventId: (null|*), nextEventId: (null|*), loadedEventId, 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,
loadedEventId: this.loadedEventId,
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.loadedEventId = event.id;
this.numEvents = timedEvents.length;
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) {
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:
break;
}
}
}
export const eventLoader = new EventLoader();
@@ -1,6 +1,6 @@
let instance;
class MessageManager {
class MessageService {
constructor() {
if (instance) {
throw new Error('There can be only one');
@@ -99,4 +99,4 @@ class MessageManager {
}
}
export const messageManager = new MessageManager();
export const messageManager = new MessageService();
+23 -50
View File
@@ -5,6 +5,7 @@ import { generateId } from '../../utils/generate_id.js';
import { stringFromMillis } from '../../utils/time.js';
import { Timer } from '../timer/Timer.js';
import { messageManager } from '../message-manager/MessageManager.js';
import { PlaybackService } from '../../services/playbackService.js';
import { ADDRESS_MESSAGE_CONTROL } from './socketConfig.js';
@@ -58,6 +59,7 @@ class SocketController {
}`;
this.info('CLIENT', message);
// Todo: review in favour of features
// send state
socket.emit('timer', global.timer.getTimeObject());
socket.emit('playstate', global.timer.state);
@@ -101,78 +103,69 @@ class SocketController {
});
socket.on('set-start', () => {
global.timer.trigger('start');
socket.emit('playstate', global.timer.state);
PlaybackService.start();
});
socket.on('set-startid', (data) => {
global.timer.trigger('startById', data);
socket.emit('playstate', global.timer.state);
if (data) {
PlaybackService.startById(data);
}
});
socket.on('set-startindex', (data) => {
const eventIndex = Number(data);
if (isNaN(eventIndex)) {
return;
if (!isNaN(eventIndex)) {
PlaybackService.startByIndex(eventIndex);
}
global.timer.trigger('startByIndex', data);
socket.emit('playstate', global.timer.state);
});
socket.on('set-loadid', (data) => {
global.timer.trigger('loadById', data);
socket.emit('playstate', global.timer.state);
if (data) {
PlaybackService.loadById(data);
}
});
socket.on('set-loadindex', (data) => {
const eventIndex = Number(data);
if (isNaN(eventIndex)) {
return;
if (!isNaN(eventIndex)) {
PlaybackService.loadByIndex(eventIndex - 1);
}
global.timer.trigger('loadByIndex', data);
socket.emit('playstate', global.timer.state);
});
socket.on('set-pause', () => {
global.timer.trigger('pause');
socket.emit('playstate', global.timer.state);
PlaybackService.pause();
});
socket.on('set-stop', () => {
global.timer.trigger('stop');
socket.emit('playstate', global.timer.state);
PlaybackService.stop();
});
socket.on('set-reload', () => {
global.timer.trigger('reload');
socket.emit('playstate', global.timer.state);
PlaybackService.reload();
});
socket.on('set-previous', () => {
global.timer.trigger('previous');
socket.emit('playstate', global.timer.state);
PlaybackService.loadPrevious();
});
socket.on('set-next', () => {
global.timer.trigger('next');
socket.emit('playstate', global.timer.state);
PlaybackService.loadNext();
});
socket.on('set-roll', () => {
global.timer.trigger('roll');
socket.emit('playstate', global.timer.state);
PlaybackService.roll();
});
socket.on('set-delay', (data) => {
const delayTime = Number(data);
if (isNaN(delayTime)) {
return;
if (!isNaN(delayTime)) {
PlaybackService.setDelay(delayTime);
}
global.timer.increment(delayTime * 1000 * 60);
});
/*******************************************/
// general playback state, useful for external sync
// Todo: add delayed value (will come from rundownService)
socket.on('ontime-poll', () => {
const timerPoll = global.timer.poll();
const isDelayed = false;
@@ -180,34 +173,14 @@ class SocketController {
socket.emit('ontime-poll', { isDelayed, colour, ...timerPoll });
});
/*******************************************/
// ** TO BE DEPRECATED ** //
socket.on('get-timer', () => {
socket.emit('timer', global.timer.getTimeObject());
});
// ** TO BE DEPRECATED IN FAVOR OF DELAY ** //
socket.on('increment-timer', (data) => {
if (isNaN(parseInt(data, 10))) return;
if (data < -5 || data > 5) return;
global.timer.increment(data * 1000 * 60);
});
/*******************************************/
// playstate
socket.on('set-playstate', (data) => {
global.timer.trigger(data);
global.timer._broadcastFeaturePlaybackControl();
global.timer._broadcastFeatureInfo();
});
socket.on('get-playstate', () => {
socket.emit('playstate', global.timer.state);
});
socket.on('get-onAir', () => {
socket.emit('onAir', global.timer.onAir);
socket.emit('onAir', messageManager.onAir);
});
/*******************************************/
+120 -650
View File
@@ -1,14 +1,15 @@
import { Timer } from './Timer.js';
import { DAY_TO_MS, getSelectionByRoll, replacePlaceholder, updateRoll } from './classUtils.js';
import { DAY_TO_MS, replacePlaceholder, updateRoll } from './classUtils.js';
import { OSCIntegration } from './integrations/Osc.js';
import { HTTPIntegration } from './integrations/Http.js';
import { cleanURL } from '../../utils/url.js';
import { EventLoader, eventLoader } from '../event-loader/EventLoader.js';
import { DataProvider } from '../data-provider/DataProvider.js';
/*
* Class EventTimer adds functions specific to APP
* @extends Timer
*/
export class EventTimer extends Timer {
/**
* Instantiates an event timer object
@@ -50,8 +51,6 @@ export class EventTimer extends Timer {
// call general title reset
this._resetSelection();
this.rundown = [];
// set recurrent emits
this._interval = setInterval(() => this.runCycle(), timerConfig?.refresh || 1000);
@@ -144,10 +143,11 @@ export class EventTimer extends Timer {
* @private
*/
_broadcastFeaturePlaybackControl() {
const numEvents = DataProvider.getNumEvents();
const featureData = {
playback: this.state,
selectedEventId: this.selectedEventId,
numEvents: this.rundown.length,
numEvents: numEvents,
};
this.socket.send('feat-playbackcontrol', featureData);
}
@@ -157,22 +157,24 @@ export class EventTimer extends Timer {
* @private
*/
_broadcastFeatureInfo() {
const numEvents = DataProvider.getNumEvents();
const featureData = {
titles: this.titles,
playback: this.state,
selectedEventId: this.selectedEventId,
selectedEventIndex: this.selectedEventIndex,
numEvents: this.rundown.length,
numEvents: numEvents,
};
this.socket.send('feat-info', featureData);
}
_broadcastFeatureCuesheet() {
const numEvents = DataProvider.getNumEvents();
const featureData = {
playback: this.state,
selectedEventId: this.selectedEventId,
selectedEventIndex: this.selectedEventIndex,
numEvents: this.rundown.length,
numEvents: numEvents,
titleNow: this.titles.titleNow,
};
this.socket.send('feat-cuesheet', featureData);
@@ -189,146 +191,16 @@ export class EventTimer extends Timer {
this._broadcastFeatureCuesheet();
this._broadcastFeatureTimer();
const numEvents = this.rundown.length;
this.broadcastTimer();
this.socket.send('playstate', this.state);
this.socket.send('selected', {
id: this.selectedEventId,
index: this.selectedEventIndex,
total: numEvents,
});
this.socket.send('selected-id', this.selectedEventId);
this.socket.send('next-id', this.nextEventId);
this.socket.send('numevents', numEvents);
this.socket.send('publicselected-id', this.selectedPublicEventId);
this.socket.send('publicnext-id', this.nextPublicEventId);
this.socket.send('titles', this.titles);
this.socket.send('publictitles', this.titlesPublic);
}
/**
* @description Interface for triggering playback actions
* @param {string} action - state to be triggered
* @param {string|number} [payload] - optional action payload
* @returns {boolean} Whether action was called
*/
trigger(action, payload) {
let success = true;
const numEvents = this.rundown.length;
switch (action) {
case 'start': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Play Mode Start');
this.start();
break;
}
case 'startById': {
if (!numEvents) return false;
const loaded = this.loadEventById(payload);
if (loaded) {
this.socket.info('PLAYBACK', `Loaded event with ID ${payload}`);
this.socket.info('PLAYBACK', 'Play Mode Start');
this.start();
} else {
return false;
}
break;
}
case 'startByIndex': {
if (!numEvents) return false;
const loaded = this.loadEventByIndex(payload);
if (loaded) {
this.socket.info('PLAYBACK', `Loaded event with index ${payload}`);
this.socket.info('PLAYBACK', 'Play Mode Start');
this.start();
} else {
return false;
}
break;
}
case 'pause': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Play Mode Pause');
this.pause();
break;
}
case 'stop': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Play Mode Stop');
this.stop();
break;
}
case 'roll': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Play Mode Roll');
this.roll();
break;
}
case 'previous': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Play Mode Previous');
this.previous();
break;
}
case 'next': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Play Mode Next');
this.next();
break;
}
case 'loadById': {
if (!numEvents) return false;
const loaded = this.loadEventById(payload);
if (loaded) {
this.socket.info('PLAYBACK', `Loaded event with ID ${payload}`);
} else {
return false;
}
break;
}
case 'loadByIndex': {
if (!numEvents) return false;
const loaded = this.loadEventByIndex(payload);
if (loaded) {
this.socket.info('PLAYBACK', `Loaded event with index ${payload}`);
} else {
return false;
}
break;
}
case 'unload': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Events unloaded');
this.unload();
break;
}
case 'reload': {
if (!numEvents) return false;
// Call action and force update
this.socket.info('PLAYBACK', 'Reloaded event');
this.reload();
break;
}
default: {
// Error, disable flag
this.socket.error('RX', `Unhandled action triggered ${action}`);
success = false;
break;
}
}
// update state
this.runCycle();
return success;
}
/**
* @description State machine checks what actions need to
* happen at every app cycle
@@ -530,467 +402,59 @@ export class EventTimer extends Timer {
}
/**
* Deletes running event list from object
*/
clearEventList() {
// unload events
this.unload();
// set general
this.rundown = [];
// update lifecycle: onStop
this.ontimeCycle = this.cycleState.onStop;
// update clients
this.socket.send('numevents', this.rundown.length);
}
/**
* Adds an event list to object
* @param {array} eventlist
*/
setupWithEventList(eventlist) {
if (!Array.isArray(eventlist) || !eventlist.length) return;
// filter only events
const events = eventlist.filter((e) => e.type === 'event');
const numEvents = events.length;
// set general
this.rundown = events;
// list may contain no events
if (numEvents < 1) return;
// load first event
this.loadEvent(0);
// update clients
this.broadcastState();
// run cycle
this.runCycle();
}
/**
* Updates event list in object
* @param {array} eventlist
*/
updateEventList(eventlist) {
if (!Array.isArray(eventlist) || !eventlist.length) return;
// filter only events
const events = eventlist.filter((e) => e.type === 'event' && !e.skip);
const numEvents = events.length;
// set general
this.rundown = events;
// list may be empty
if (numEvents < 1) {
this.unload();
return;
}
// auto load if is the there was nothing before
if (!this.rundown.length) {
this.loadEvent(0);
} else if (this.selectedEventId != null) {
// handle reload selected
// Look for event (order might have changed)
const eventIndex = this.rundown.findIndex((e) => e.id === this.selectedEventId);
// Maybe is missing
if (eventIndex === -1) {
this._resetTimers();
this._resetSelection();
return;
}
// Reload data if running
const type = this._startedAt != null ? 'reload' : 'load';
this.loadEvent(eventIndex, type);
}
// update clients
this.broadcastState();
// run cycle
this.runCycle();
}
/**
* Updates a single id in the object list
* @param {string} id
* @param {object} entry - new event object
*/
updateSingleEvent(id, entry) {
// find object in events
const eventIndex = this.rundown.findIndex((e) => e.id === id);
if (eventIndex === -1) {
throw new Error('Event not found');
}
// check if event is set to be skipped
if (entry.skip) {
// stop event if running
if (id === this.selectedEventId) {
this.trigger('stop');
}
// delete event
this.deleteId(id);
}
// update event in memory
const e = this.rundown[eventIndex];
this.rundown[eventIndex] = { ...e, ...entry };
try {
// check if entry is running
if (e.id === this.selectedEventId) {
// handle reload selected
// Reload data if running
const type = this.selectedEventId === id && this._startedAt != null ? 'reload' : 'load';
this.loadEvent(this.selectedEventIndex, type);
} else if (e.id === this.nextEventId) {
// roll needs to recalculate
if (this.state === 'roll') {
this.rollLoad();
}
}
// load titles
if ('title' in e || 'subtitle' in e || 'presenter' in e) {
this._loadTitlesNext();
this._loadTitlesNow();
}
} catch (error) {
this.socket.error('SERVER', error);
}
// update clients
this.broadcastState();
// run cycle
this.runCycle();
}
/**
* @description inserts an event after a given id
* @param event
* @param previousId
*/
insertEventAfterId(event, previousId) {
if (typeof previousId === 'undefined') {
// Insert at beginning
this.rundown.unshift(event);
} else {
// find object in events
const previousIndex = this.rundown.findIndex((e) => e.id === previousId);
if (previousIndex === -1) {
throw 'Event not found';
}
if (previousIndex + 1 >= this.rundown.length) {
this.rundown.push(event);
} else {
this.rundown.splice(previousIndex + 1, 0, event);
}
try {
// check if entry is running
if (event.id === this.selectedEventId) {
// handle reload selected
// Reload data if running
const type =
this.selectedEventId === event.id && this._startedAt != null ? 'reload' : 'load';
this.loadEvent(this.selectedEventIndex, type);
} else if (event.id === this.nextEventId) {
// roll needs to recalculate
if (this.state === 'roll') {
this.rollLoad();
}
}
// load titles
if ('title' in event || 'subtitle' in event || 'presenter' in event) {
this._loadTitlesNext();
this._loadTitlesNow();
}
} catch (error) {
this.socket.error('SERVER', error);
}
}
// update clients
this.broadcastState();
// run cycle
this.runCycle();
}
/**
* Deleted an event from the list by its id
*
* @param {string} eventId
*/
deleteId(eventId) {
// find object in events
const eventIndex = this.rundown.findIndex((e) => e.id === eventId);
if (eventIndex === -1) return;
// delete event and update count
this.rundown.splice(eventIndex, 1);
// reload data if necessary
if (eventId === this.selectedEventId) {
this.unload();
return;
syncLoaded(eventId) {
if (this.state === 'roll') {
this.rollLoad();
} else {
const event = EventLoader.getEventWithId(eventId);
this.loadEvent(event, 'reload');
}
// update selected event index
this.selectedEventIndex = this.rundown.findIndex((e) => e.id === this.selectedEventId);
// reload titles if necessary
if (eventId === this.nextEventId || eventId === this.nextPublicEventId) {
this._loadTitlesNext();
} else if (eventId === this.selectedPublicEventId) {
this._loadTitlesNow();
}
// update clients
this.broadcastState();
// run cycle
this.runCycle();
}
/**
* @description loads an event with a given Id
* @param {string} eventId - ID of event in eventlist
*/
loadEventById(eventId) {
const eventIndex = this.rundown.findIndex((e) => e.id === eventId);
if (eventIndex === -1) return false;
this.pause();
this.loadEvent(eventIndex, 'load');
// run cycle
this.runCycle();
return true;
}
/**
* @description loads an event with a given index
* @param {number} eventIndex - Index of event in eventlist
*/
loadEventByIndex(eventIndex) {
if (eventIndex === -1 || eventIndex > this.rundown.length) return false;
this.pause();
this.loadEvent(eventIndex, 'load');
// run cycle
this.runCycle();
return true;
}
/**
* Loads a given event by index
* @param {object} eventIndex
* @typedef ('load'|'reload') loadEventOptions
* @param {object} event
* @param {string} [type='load'] - 'load' or 'reload', whether we are keeping running time
*/
loadEvent(eventIndex, type = 'load') {
const e = this.rundown?.[eventIndex];
if (e == null) return;
loadEvent(event, type = 'load') {
const loadedData = eventLoader.loadById(event.id);
if (!loadedData) {
return;
}
const { loadedEvent, loadedEventIndex, loadedEventId, titles, titlesPublic } = loadedData;
const start = loadedEvent.timeStart || 0;
let end = loadedEvent.timeEnd || 0;
const start = e.timeStart == null || e.timeStart === '' ? 0 : e.timeStart;
let end = e.timeEnd == null || e.timeEnd === '' ? 0 : e.timeEnd;
// in case the end is earlier than start, we assume is the day after
if (end < start) end += DAY_TO_MS;
if (end < start) {
end += DAY_TO_MS;
}
// time stuff changes on whether we keep the running clock
this.duration = end - start;
this.selectedEventIndex = loadedEventIndex;
this.selectedEventId = loadedEventId;
if (type === 'load') {
this._resetTimers();
this.duration = end - start;
this.current = this.duration;
this.selectedEventIndex = eventIndex;
this.selectedEventId = e.id;
} else if (type === 'reload') {
} else {
const now = Timer.getCurrentTime();
const elapsed = this.getElapsed();
this.duration = end - start;
this.selectedEventIndex = eventIndex;
this._finishAt = now + (this.duration - elapsed);
}
// load current titles
this._loadTitlesNow();
// look for event after
this._loadTitlesNext();
this.titles = titles;
this.titlesPublic = titlesPublic;
// update lifecycle: onLoad
this.ontimeCycle = this.cycleState.onLoad;
}
/**
* @description loads given title (now)
* @private
*/
_loadTitlesNow() {
const e = this.rundown[this.selectedEventIndex];
if (e == null) return;
// private title is always current
// check if current is also public
if (e.isPublic) {
this._loadThisTitles(e, 'now');
} else {
this._loadThisTitles(e, '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 (this.rundown[i].type === 'event' && this.rundown[i].isPublic) {
this._loadThisTitles(this.rundown[i], 'now-public');
break;
}
}
}
}
/**
* @description loads given title
* @param e
* @param type
* @private
*/
_loadThisTitles(e, type) {
if (e == null) return;
switch (type) {
// now, load to both public and private
case 'now':
// public
this.titlesPublic.titleNow = e.title;
this.titlesPublic.subtitleNow = e.subtitle;
this.titlesPublic.presenterNow = e.presenter;
this.selectedPublicEventId = e.id;
// private
this.titles.titleNow = e.title;
this.titles.subtitleNow = e.subtitle;
this.titles.presenterNow = e.presenter;
this.titles.noteNow = e.note;
this.selectedEventId = e.id;
break;
case 'now-public':
this.titlesPublic.titleNow = e.title;
this.titlesPublic.subtitleNow = e.subtitle;
this.titlesPublic.presenterNow = e.presenter;
this.selectedPublicEventId = e.id;
break;
case 'now-private':
this.titles.titleNow = e.title;
this.titles.subtitleNow = e.subtitle;
this.titles.presenterNow = e.presenter;
this.titles.noteNow = e.note;
this.selectedEventId = e.id;
break;
// next, load to both public and private
case 'next':
// public
this.titlesPublic.titleNext = e.title;
this.titlesPublic.subtitleNext = e.subtitle;
this.titlesPublic.presenterNext = e.presenter;
this.nextPublicEventId = e.id;
// private
this.titles.titleNext = e.title;
this.titles.subtitleNext = e.subtitle;
this.titles.presenterNext = e.presenter;
this.titles.noteNext = e.note;
this.nextEventId = e.id;
break;
case 'next-public':
this.titlesPublic.titleNext = e.title;
this.titlesPublic.subtitleNext = e.subtitle;
this.titlesPublic.presenterNext = e.presenter;
this.nextPublicEventId = e.id;
break;
case 'next-private':
this.titles.titleNext = e.title;
this.titles.subtitleNext = e.subtitle;
this.titles.presenterNext = e.presenter;
this.titles.noteNext = e.note;
this.nextEventId = e.id;
break;
default:
break;
}
}
/**
* @description look for next titles to load
* @private
*/
_loadTitlesNext() {
// 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 = this.rundown.length;
if (this.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextPrivate = false;
for (let i = this.selectedEventIndex + 1; i < numEvents; i++) {
// check that is the right type
if (this.rundown[i].type === 'event') {
// if we have not set private
if (!nextPrivate) {
this._loadThisTitles(this.rundown[i], 'next-private');
nextPrivate = true;
}
// if event is public
if (this.rundown[i].isPublic) {
this._loadThisTitles(this.rundown[i], 'next-public');
nextPublic = true;
}
}
// Stop if both are set
if (nextPublic && nextPrivate) break;
}
}
}
/**
* @description resets selected event data
* @private
@@ -1025,51 +489,58 @@ export class EventTimer extends Timer {
/**
* @description start timer
* @return {('start'|'pause'|'stop'|'roll')} Playback state
*/
start() {
// do we need to change
if (this.state === 'start') return;
// if there is nothing selected, no nothing
if (this.selectedEventId == null) return;
if (this.state === 'start') return 'start';
// call super
super.start();
// update lifecycle: onStart
this.ontimeCycle = this.cycleState.onStart;
return this.state;
}
/**
* @description pause timer
* @return {('start'|'pause'|'stop')} Playback state
*/
pause() {
// do we need to change
if (this.state === 'pause') return;
// if there is nothing selected, no nothing
if (this.selectedEventId == null) return;
if (this.state === 'pause') return 'pause';
// call super
super.pause();
// update lifecycle: onPause
this.ontimeCycle = this.cycleState.onPause;
return this.state;
}
/**
* @description stop timer
* @return {('start'|'pause'|'stop'|'roll')} Playback state
*/
stop() {
// do we need to change
if (this.state === 'stop') return;
if (this.state === 'stop') return 'stop';
// call super
super.stop();
this._resetTimers(true);
this._resetSelection();
// update lifecycle: onPause
// update lifecycle: onStop
this.ontimeCycle = this.cycleState.onStop;
// broadcast state
this.broadcastState();
return this.state;
}
/**
@@ -1088,7 +559,6 @@ export class EventTimer extends Timer {
* @description Look for current event considering local clock
*/
rollLoad() {
const now = Timer.getCurrentTime();
const prevLoaded = this.selectedEventId;
// maybe roll has already been loaded
@@ -1098,11 +568,11 @@ export class EventTimer extends Timer {
}
const { nowIndex, nowId, publicIndex, nextIndex, publicNextIndex, timers, timeToNext } =
getSelectionByRoll(this.rundown, now);
eventLoader.findRoll();
// nothing to play, unload
if (nowIndex === null && nextIndex === null) {
this.unload();
this.stop();
this.socket.warning('SERVER', 'Roll: no events found');
return;
}
@@ -1126,6 +596,8 @@ export class EventTimer extends Timer {
// found something to run next
if (nextIndex != null) {
const eventNext = EventLoader.getPlayableAtIndex(nextIndex);
// Set running timers
if (nowIndex === null) {
// only warn the first time
@@ -1139,25 +611,61 @@ export class EventTimer extends Timer {
// timer counts to next event
this.secondaryTimer = timeToNext;
this._secondaryTarget = this.rundown[nextIndex].timeStart;
this._secondaryTarget = eventNext.timeStart;
}
// TITLES: Load next private
this._loadThisTitles(this.rundown[nextIndex], 'next-private');
// Todo: this should be an ID
// todo: this logic should be removed
if (eventNext) {
this.titles.titleNext = eventNext.title;
this.titles.subtitleNext = eventNext.subtitle;
this.titles.presenterNext = eventNext.presenter;
this.titles.noteNext = eventNext.note;
this.nextEventId = eventNext.id;
}
}
// Todo: this should be an ID
// todo: this logic should be removed
// TITLES: Load next public
if (publicNextIndex !== null) {
this._loadThisTitles(this.rundown[publicNextIndex], 'next-public');
const eventNextPublic = EventLoader.getPlayableAtIndex(publicNextIndex);
if (eventNextPublic) {
this.titlesPublic.titleNext = eventNextPublic.title;
this.titlesPublic.subtitleNext = eventNextPublic.subtitle;
this.titlesPublic.presenterNext = eventNextPublic.presenter;
this.titlesPublic.noteNext = eventNextPublic.note;
this.nextPublicEventId = eventNextPublic.id;
}
}
// Todo: this should be an ID
// todo: this logic should be removed
// TITLES: Load now private
if (nowIndex !== null) {
this._loadThisTitles(this.rundown[nowIndex], 'now-private');
const eventNowPrivate = EventLoader.getPlayableAtIndex(nowIndex);
if (eventNowPrivate) {
this.titles.titleNow = eventNowPrivate.title;
this.titles.subtitleNow = eventNowPrivate.subtitle;
this.titles.presenterNow = eventNowPrivate.presenter;
this.titles.noteNow = eventNowPrivate.note;
this.selectedEventId = eventNowPrivate.id;
}
}
// Todo: this should be an ID
// todo: this logic should be removed
// TITLES: Load now public
if (publicIndex !== null) {
this._loadThisTitles(this.rundown[publicIndex], 'now-public');
const eventNowPublic = EventLoader.getPlayableAtIndex(nowIndex);
if (eventNowPublic) {
this.titlesPublic.titleNow = eventNowPublic.title;
this.titlesPublic.subtitleNow = eventNowPublic.subtitle;
this.titlesPublic.presenterNow = eventNowPublic.presenter;
this.titlesPublic.noteNow = eventNowPublic.note;
this.selectedPublicEventId = eventNowPublic.id;
}
}
if (prevLoaded !== this.selectedEventId) {
@@ -1168,13 +676,15 @@ export class EventTimer extends Timer {
}
}
/**
* Starts roll mode
* @return {('start'|'pause'|'stop'|'roll')} Playback state
*/
roll() {
// do we need to change
if (this.state === 'roll') return;
if (this.state === 'roll') {
return 'roll';
}
if (!this.rundown.length) return;
// set state
this.state = 'roll';
// update lifecycle: armed
@@ -1182,75 +692,30 @@ export class EventTimer extends Timer {
// load into event
this.rollLoad();
return this.state;
}
previous() {
// check that we have events to run
if (!this.rundown.length) return;
// maybe this is the first event?
if (this.selectedEventIndex === 0) return;
// if there is no event running, go to first
if (!this.selectedEventIndex) {
this.loadEvent(0);
} else {
const gotoEvent = this.selectedEventIndex > 0 ? this.selectedEventIndex - 1 : 0;
if (gotoEvent === this.selectedEventIndex) return;
this.loadEvent(gotoEvent);
}
// send OSC
this.sendOsc(this.osc.implemented.previous);
// change playstate
this.pause();
this.runCycle();
}
next() {
const numEvents = this.rundown.length;
// check that we have events to run
if (!numEvents) return;
// maybe this is the last event?
if (this.selectedEventIndex === numEvents - 1) return;
// if there is no event running, go to first
if (this.selectedEventIndex === null) {
this.loadEvent(0);
} else {
const gotoEvent =
this.selectedEventIndex < numEvents - 1 ? this.selectedEventIndex + 1 : numEvents - 1;
if (gotoEvent === this.selectedEventIndex) return;
this.loadEvent(gotoEvent);
}
// send OSC
this.sendOsc(this.osc.implemented.next);
// change playstate
this.pause();
}
unload() {
// reset timer
this._resetTimers(true);
// reset selected
this._resetSelection();
// broadcast state
this.broadcastState();
// reset playstate
this.stop();
this.runCycle();
}
/**
* @description reloads current event
* @return {('start'|'pause'|'stop'|'roll')} Playback state
*/
reload() {
if (!this.rundown.length) return;
if (!this.selectedEventId) {
return this.state;
}
// change playstate
this.pause();
@@ -1259,7 +724,12 @@ export class EventTimer extends Timer {
this.sendOsc(this.osc.implemented.reload);
// reload data
this.loadEvent(this.selectedEventIndex);
const event = EventLoader.getEventWithId(this.selectedEventId);
this.loadEvent(event);
this.runCycle();
return this.state;
}
/****************************************************************************/
+1 -2
View File
@@ -75,9 +75,8 @@ export class Timer {
}
/**
* @description get current time in epoc
* @description get current time in ms from midnight
* @return {number}
* @private
*/
static getCurrentTime() {
const now = new Date();
@@ -60,61 +60,6 @@ test('object instantiates correctly', async () => {
expect(t.nextEventId).toBeNull();
expect(t.selectedPublicEventId).toBeNull();
expect(t.nextPublicEventId).toBeNull();
expect(t.rundown.length).toBe(0);
expect(t.rundown).toStrictEqual([]);
expect(t.onAir).toBeFalsy();
t.shutdown();
});
describe('test triggers behaviour', () => {
const t = new EventTimer(mockSocket, timerConfig);
test('ignores bad commands', (done) => {
const success = t.trigger('test');
expect(success).toBeFalsy();
done();
});
test('does not allow triggering events with an empty list', (done) => {
expect(t.rundown.length).toBe(0);
expect(t.trigger('start')).toBeFalsy();
expect(t.trigger('pause')).toBeFalsy();
expect(t.trigger('stop')).toBeFalsy();
expect(t.trigger('roll')).toBeFalsy();
expect(t.trigger('previous')).toBeFalsy();
expect(t.trigger('next')).toBeFalsy();
expect(t.trigger('reload')).toBeFalsy();
done();
});
test('...and is consistent by calling the class methods', (done) => {
expect(t.rundown.length).toBe(0);
expect(t.state).toBe('stop');
t.start();
expect(t.state).toBe('stop');
t.pause();
expect(t.state).toBe('stop');
t.stop();
expect(t.state).toBe('stop');
t.roll();
expect(t.state).toBe('stop');
t.previous();
expect(t.state).toBe('stop');
t.next();
expect(t.state).toBe('stop');
t.reload();
expect(t.state).toBe('stop');
done();
});
t.shutdown();
});
+48 -44
View File
@@ -1,9 +1,13 @@
import { Server } from 'node-osc';
import { PlaybackService } from '../services/playbackService';
import { messageManager } from '../classes/message-manager/MessageManager.js';
import { socketProvider } from '../classes/socket/SocketController.js';
import { ADDRESS_MESSAGE_CONTROL } from '../classes/socket/socketConfig.js';
let oscServer = null;
/**
* @description utilty function to shutdown osc server
* @description utility function to shut down osc server
*/
export const shutdownOSCServer = () => {
if (oscServer != null) oscServer.close();
@@ -11,19 +15,18 @@ export const shutdownOSCServer = () => {
/**
* @description initialises OSC server
* @param config
* @param {object} config
*/
export const initiateOSC = (config) => {
oscServer = new Server(config.port, '0.0.0.0');
// error
oscServer.on('error', console.error);
oscServer.on('message', function (msg) {
// message should look like /ontime/{path}/{args} where
// 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 of the API entries (delay, goto)
// args: extra data, only used on some API entries (delay, goto)
// split message
const [, address, path] = msg[0].split('/');
@@ -31,122 +34,123 @@ export const initiateOSC = (config) => {
// get first part before (ontime)
if (address !== 'ontime') {
console.error(`OSC IN: Message address ${address} not recognised`);
return;
}
if (path == null) {
console.error('OSC IN: No path found');
console.error('RX', `OSC IN: Message address ${address} not recognised`);
return;
}
// get second part (command)
if (!path) {
console.error('RX', 'OSC IN: No path found');
return;
}
switch (path.toLowerCase()) {
case 'onair': {
global.timer.setonAir(true);
const featureData = messageManager.setOnAir(true);
socketProvider.send(ADDRESS_MESSAGE_CONTROL, featureData);
break;
}
case 'offair': {
global.timer.setonAir(false);
const featureData = messageManager.setOnAir(false);
socketProvider.send(ADDRESS_MESSAGE_CONTROL, featureData);
break;
}
case 'play': {
global.timer.trigger('start');
PlaybackService.start();
break;
}
case 'start': {
try {
const eventIndex = Number(args);
if (isNaN(eventIndex)) {
global.timer.error('RX', `OSC IN: event index not recognised ${args}`);
socketProvider.error('RX', `OSC IN: event index not recognised ${args}`);
return;
}
const success = global.timer.trigger('startByIndex', eventIndex);
if (!success) {
global.timer.error('RX', `OSC IN: event index not recognised ${args}`);
}
PlaybackService.startByIndex(eventIndex);
} catch (error) {
console.log('error parsing: ', error);
console.log('Error loading event: ', error);
}
break;
}
case 'startid': {
const success = global.timer.trigger('startById', args);
if (!success) {
global.timer.error('RX', `OSC IN: event ID not recognised ${args}`);
if (!args) {
socketProvider.error('RX', `OSC IN: No ID in request`);
return;
}
PlaybackService.loadById(args);
break;
}
case 'pause': {
global.timer.trigger('pause');
PlaybackService.pause();
break;
}
case 'prev': {
global.timer.trigger('previous');
PlaybackService.loadPrevious();
break;
}
case 'next': {
global.timer.trigger('next');
PlaybackService.loadNext();
break;
}
case 'unload':
case 'stop': {
global.timer.trigger('unload');
PlaybackService.stop();
break;
}
case 'reload': {
global.timer.trigger('reload');
PlaybackService.reload();
break;
}
case 'roll': {
global.timer.trigger('roll');
PlaybackService.roll();
break;
}
case 'delay': {
try {
const t = parseInt(args, 10);
if (isNaN(t)) {
global.timer.error('RX', `OSC IN: delay time not recognised ${args}`);
const delayTime = Number(args);
if (isNaN(delayTime)) {
socketProvider.error('RX', `OSC IN: delay time not recognised ${args}`);
return;
}
global.timer.increment(t * 1000 * 60);
PlaybackService.setDelay(delayTime);
} catch (error) {
console.log('error parsing: ', error);
console.log('Error adding delay: ', error);
}
break;
}
case 'goto':
case 'load': {
try {
const eventIndex = parseInt(args, 10);
const eventIndex = Number(args);
if (isNaN(eventIndex) || eventIndex <= 0) {
global.timer.error(
socketProvider.error(
'RX',
`OSC IN: event index not recognised or out of range ${eventIndex}`
`OSC IN: event index not recognised or out of range ${eventIndex}`,
);
} else {
PlaybackService.loadByIndex(eventIndex - 1);
}
global.timer.loadEventByIndex(eventIndex - 1);
} catch (error) {
global.timer.error('RX', `OSC IN: error calling goto ${error}`);
socketProvider.error('RX', `OSC IN: error calling goto ${error}`);
}
break;
}
case 'gotoid':
case 'loadid': {
if (args == null) {
global.timer.error('RX', `OSC IN: event id not recognised or out of range ${args}}`);
if (!args) {
socketProvider.error('RX', `OSC IN: event ID not recognised: ${args}}`);
return;
}
try {
global.timer.loadEventById(args.toString().toLowerCase());
PlaybackService.loadById(args.toString().toLowerCase());
} catch (error) {
global.timer.error('RX', `OSC IN: error calling goto ${error}`);
socketProvider.error('RX', `OSC IN: error calling goto ${error}`);
}
break;
}
default: {
global.timer.warning('RX', `OSC IN: unhandled message ${path}`);
socketProvider.warning('RX', `OSC IN: unhandled message ${path}`);
break;
}
}
+10 -1
View File
@@ -6,6 +6,7 @@ import { resolveDbPath } from '../modules/loadDb.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { mergeObject } from '../utils/parserUtils.js';
import { PlaybackService } from '../services/playbackService.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
@@ -36,6 +37,14 @@ export const dbDownload = async (req, res) => {
});
};
/**
* handles file upload
* @param file
* @param req
* @param res
* @param options
* @returns {Promise<void>}
*/
const uploadAndParse = async (file, req, res, options) => {
if (!fs.existsSync(file)) {
res.status(500).send({ message: 'Upload failed' });
@@ -48,6 +57,7 @@ const uploadAndParse = async (file, req, res, options) => {
if (result?.error) {
res.status(400).send({ message: result.message });
} else if (result.message === 'success') {
PlaybackService.stop();
// explicitly write objects
if (typeof result !== 'undefined') {
const newRundown = result.data.rundown || [];
@@ -56,7 +66,6 @@ const uploadAndParse = async (file, req, res, options) => {
} else {
await DataProvider.mergeIntoData(result.data);
}
global.timer.setupWithEventList(newRundown.filter((entry) => entry.type === 'event'));
}
res.sendStatus(200);
} else {
+39 -31
View File
@@ -1,75 +1,81 @@
// Create controller for GET request to '/playback'
// Returns ACK message
import { PlaybackService } from '../services/playbackService.js';
// Create controller for POST request to '/playback'
// Returns playback state
export const pbGet = async (req, res) => {
res.send({ playback: global.timer.state });
};
// Create controller for GET request to '/playback/start'
// Create controller for POST request to '/playback/start'
// Starts timer object
export const pbStart = async (req, res) => {
const { eventId, eventIndex } = req.query;
if (eventId) {
global.timer.trigger('startById', eventId)
? res.sendStatus(200)
: res.status(400).send('Invalid event ID');
const success = PlaybackService.startById(eventId);
success ? res.sendStatus(202) : res.status(400).send('Invalid event ID');
} else if (eventIndex) {
const index = Number(eventIndex);
if (!isNaN(index)) {
global.timer.trigger('startByIndex', index - 1)
? res.sendStatus(200)
: res.status(400).send('Invalid event index');
const success = PlaybackService.startByIndex(eventIndex - 1);
success ? res.sendStatus(202) : res.status(400).send('Invalid event index');
} else {
res.status(400).send('Invalid event index');
}
} else {
global.timer.trigger('start') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.start();
res.sendStatus(202);
}
};
// Create controller for GET request to '/playback/pause'
// Create controller for POST request to '/playback/pause'
// Pauses timer object
export const pbPause = async (req, res) => {
global.timer.trigger('pause') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.pause();
res.sendStatus(202);
};
// Create controller for GET request to '/playback/stop'
// Create controller for POST request to '/playback/stop'
// Stops timer object
export const pbStop = async (req, res) => {
global.timer.trigger('stop') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.stop();
res.sendStatus(202);
};
// Create controller for GET request to '/playback/roll'
// Create controller for POST request to '/playback/roll'
// Sets timer object to roll mode
export const pbRoll = async (req, res) => {
global.timer.trigger('roll') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.roll();
res.sendStatus(202);
};
// Create controller for GET request to '/playback/previous'
// Sets timer object to roll mode
// Create controller for POST request to '/playback/previous'
// Loads previous event
export const pbPrevious = async (req, res) => {
global.timer.trigger('previous') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.loadPrevious();
res.sendStatus(202);
};
// Create controller for GET request to '/playback/next'
// Sets timer object to roll mode
// Create controller for POST request to '/playback/next'
// Loads Next event
export const pbNext = async (req, res) => {
global.timer.trigger('next') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.loadNext();
res.sendStatus(202);
};
// Create controller for GET request to '/playback/load'
// Create controller for POST request to '/playback/load'
// Load requested event
export const pbLoad = async (req, res) => {
const { eventId, eventIndex } = req.query;
if (eventId) {
global.timer.trigger('loadById', eventId)
? res.sendStatus(200)
: res.status(400).send('Invalid event ID');
const success = PlaybackService.loadById(eventId);
success ? res.sendStatus(202) : res.status(400).send('Invalid event ID');
} else if (eventIndex) {
const index = Number(eventIndex);
if (!isNaN(index)) {
global.timer.trigger('loadByIndex', index - 1)
? res.sendStatus(200)
: res.status(400).send('Invalid event index');
const success = PlaybackService.loadByIndex(eventIndex - 1);
success ? res.sendStatus(202) : res.status(400).send('Invalid event index');
} else {
res.status(400).send('Invalid event index');
}
@@ -78,14 +84,16 @@ export const pbLoad = async (req, res) => {
}
};
// Create controller for GET request to '/playback/unload'
// Create controller for POST request to '/playback/unload'
// Unloads any events
export const pbUnload = async (req, res) => {
global.timer.trigger('unload') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.stop();
res.sendStatus(202);
};
// Create controller for GET request to '/playback/reload'
// Create controller for POST request to '/playback/reload'
// Reloads current event
export const pbReload = async (req, res) => {
global.timer.trigger('reload') ? res.sendStatus(200) : res.sendStatus(400);
PlaybackService.reload();
res.sendStatus(202);
};
+21 -223
View File
@@ -1,80 +1,13 @@
import {
block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js';
import { generateId } from '../utils/generate_id.js';
import { MAX_EVENTS } from '../settings.js';
import { getPreviousPlayable } from '../utils/eventUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { socketProvider } from '../classes/socket/SocketController.js';
// import socket provider
const socket = socketProvider;
async function _insertAndSync(newEvent) {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
if (newEvent.type === 'event') {
_insertEventInTimerAfterId(newEvent);
}
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
if (newEvent.type === 'event') {
const rundown = DataProvider.getRundown();
const { id } = getPreviousPlayable(rundown, newEvent.id);
_insertEventInTimerAfterId(newEvent, id);
}
}
}
/**
* @description returns all events of type event
* @return {unknown[]}
*/
function getEventEvents() {
// return data.events.filter((e) => e.type === 'event');
const rundown = DataProvider.getRundown();
return Array.from(rundown).filter((e) => e.type === 'event');
}
// Updates timer object
function _updateTimers() {
const results = getEventEvents();
global.timer.updateEventList(results);
}
/**
* @description Adds an event to the timer after an event with given id
* @param {object} event
* @param {string} [previousId]
* @private
*/
function _insertEventInTimerAfterId(event, previousId) {
try {
global.timer.insertEventAfterId(event, previousId);
} catch (error) {
socket.error('SERVER', `Unable to update object: ${error}`);
}
}
/**
* @description Updates timer object single event
* @param {string} id
* @param {object} event
* @private
*/
function _updateTimersSingle(id, event) {
global.timer.updateSingleEvent(id, event);
}
// Delete a single entry in timer object
function _deleteTimerId(entryId) {
global.timer.deleteId(entryId);
}
import {
addEvent,
applyDelay,
deleteAllEvents,
deleteEvent,
editEvent,
reorderEvent,
} from '../services/rundownService.js';
// Create controller for GET request to '/eventlist'
// Returns -
@@ -85,14 +18,7 @@ export const rundownGetAll = async (req, res) => {
// Create controller for GET request to '/eventlist/:eventId'
// Returns -
export const getEventById = async (req, res) => {
const id = req.params?.eventId;
if (id == null) {
res.status(400).send(`No eventId found in request`);
} else {
const event = DataProvider.getEventById(id);
res.json(event);
}
res.json(DataProvider.getEventById(req.params?.eventId));
};
// Create controller for POST request to '/eventlist/'
@@ -102,36 +28,9 @@ export const rundownPost = async (req, res) => {
return;
}
const numEvents = DataProvider.getNumEvents();
if (numEvents > MAX_EVENTS) {
const error = `ERROR: Reached limit number of ${MAX_EVENTS} events`;
res.status(400).send(error);
return;
}
// ensure structure
let newEvent = {};
const id = generateId();
switch (req.body.type) {
case 'event':
newEvent = { ...eventDef, ...req.body, id };
break;
case 'delay':
newEvent = { ...delayDef, ...req.body, id };
break;
case 'block':
newEvent = { ...blockDef, ...req.body, id };
break;
default:
res.status(400).send(`Object type missing or unrecognised: ${req.body.type}`);
break;
}
try {
await _insertAndSync(newEvent);
res.sendStatus(201);
const newEvent = await addEvent(req.body);
res.status(201).send(newEvent);
} catch (error) {
res.status(400).send(error);
}
@@ -144,35 +43,9 @@ export const rundownPut = async (req, res) => {
return;
}
const eventDataFromRequest = req.body;
const eventId = eventDataFromRequest.id;
const eventInMemory = DataProvider.getEventById(eventId);
if (typeof eventInMemory === 'undefined') {
res.status(400).send(`No event with ID found`);
return;
}
try {
const patchedObject = await DataProvider.updateEventById(eventId, eventDataFromRequest);
if (patchedObject.type === 'event') {
if (patchedObject.skip) {
// if it is a skip, make sure it is deleted from timer
_deleteTimerId(patchedObject.id);
} else {
if (eventInMemory.skip) {
// if it was skipped before we add it to the timer
const rundown = DataProvider.getRundown();
const { id } = getPreviousPlayable(rundown, patchedObject.id);
_insertEventInTimerAfterId(patchedObject, id);
} else {
// otherwise update as normal
_updateTimersSingle(patchedObject.id, patchedObject);
}
}
}
res.sendStatus(200);
const event = await editEvent(req.body);
res.status(200).send(event);
} catch (error) {
res.status(400).send(error);
}
@@ -183,32 +56,10 @@ export const rundownReorder = async (req, res) => {
return;
}
const { index, from, to } = req.body;
// get rundown
const rundown = DataProvider.getRundown();
const idx = rundown.findIndex((e) => e.id === index, from);
// Check if item is at given index
if (idx !== from) {
res.status(400).send(`Id not found at index`);
return;
}
try {
// remove item at from
const [reorderedItem] = rundown.splice(from, 1);
// reinsert item at to
rundown.splice(to, 0, reorderedItem);
// save rundown
await DataProvider.setEventData(rundown);
// update timer
_updateTimers();
res.sendStatus(200);
const { eventId, from, to } = req.body;
const event = await reorderEvent(eventId, from, to);
res.status(200).send(event);
} catch (error) {
res.status(400).send(error);
}
@@ -218,54 +69,8 @@ export const rundownReorder = async (req, res) => {
// Returns -
export const rundownApplyDelay = async (req, res) => {
try {
// get rundown
const rundown = DataProvider.getRundown();
// AUX
let delayIndex = null;
let blockIndex = null;
let delayValue = 0;
for (const [index, e] of rundown.entries()) {
if (delayIndex == null) {
// look for delay
if (e.id === req.params.eventId && e.type === 'delay') {
delayValue = e.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (e.type === 'event') {
// update times
e.timeStart += delayValue;
e.timeEnd += delayValue;
// increment revision
e.revision += 1;
} else if (e.type === 'block') {
// save id and stop
blockIndex = index;
break;
}
}
}
// delete delay
rundown.splice(delayIndex, 1);
// delete block
// index would have moved down since we deleted delay
if (blockIndex) rundown.splice(blockIndex - 1, 1);
// update rundown
await DataProvider.setRundown(rundown);
// update timer
_updateTimers();
res.sendStatus(201);
await applyDelay(req.params.eventId);
res.sendStatus(200);
} catch (error) {
res.status(400).send(error);
}
@@ -275,25 +80,18 @@ export const rundownApplyDelay = async (req, res) => {
// Returns -
export const deleteEventById = async (req, res) => {
try {
const eventId = req.params.eventId;
// remove new event
await DataProvider.deleteEvent(eventId);
// update timer
_deleteTimerId(eventId);
await deleteEvent(req.params.eventId);
res.sendStatus(204);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for DELETE request to '/eventlist/:eventId'
// Create controller for DELETE request to '/eventlist/'
// Returns -
export const rundownDelete = async (req, res) => {
try {
await DataProvider.clearRundown();
global.timer.clearEventList();
await deleteAllEvents();
res.sendStatus(204);
} catch (error) {
res.status(400).send(error);
@@ -18,6 +18,17 @@ export const rundownPutValidator = [
},
];
export const rundownReorderValidator = [
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
body('to').isNumeric().exists(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const paramsMustHaveEventId = [
param('eventId').exists(),
(req, res, next) => {
+18 -2
View File
@@ -1,6 +1,7 @@
import { JSONFile, Low } from 'lowdb';
import { join } from 'path';
import { dirname, join } from 'path';
import { copyFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { ensureDirectory, getAppDataPath } from '../utils/fileManagement.js';
import { config } from '../config/config.js';
import { validateFile } from '../utils/parserUtils.js';
@@ -63,7 +64,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
* @param runningDirectory
* @return {Promise<{data: (number|*), db: Low<unknown>}>}
*/
export default async function loadDb(runningDirectory) {
async function loadDb(runningDirectory) {
const dbInDisk = populateDb(runningDirectory);
const adapter = new JSONFile(dbInDisk);
@@ -76,3 +77,18 @@ export default async function loadDb(runningDirectory) {
return { db, data };
}
const filename = fileURLToPath(import.meta.url);
export const dbDirectory = dirname(join(filename, '../'));
export let db = {};
export let data = {};
export const promise = loadDb(dbDirectory);
const init = async () => {
const dbProvider = await promise;
db = dbProvider.db;
data = dbProvider.data;
};
init();
@@ -1,7 +1,12 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
import { promise } from '../../modules/loadDb.js';
beforeAll(async () => {
await promise;
startServer();
});
beforeAll(() => startServer());
afterAll(() => shutdown());
describe('When a GET request request is sent', () => {
@@ -1,7 +1,12 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
import { server, shutdown, startServer } from '../../app.js';
import { promise } from '../../modules/loadDb.js';
beforeAll(async () => {
await promise;
startServer();
});
beforeAll(() => startServer());
afterAll(() => shutdown());
describe('When a GET request request is sent', () => {
@@ -12,12 +17,12 @@ describe('When a GET request request is sent', () => {
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
expect(response.body).toBeDefined();
expect(typeof response.body.currentId).toBe('string');
expect(typeof response.body.timer).toBe('string');
expect(typeof response.body.clock).toBe('number');
expect(typeof response.body.playback).toBe('string');
expect(typeof response.body.title).toBe('string');
expect(typeof response.body.presenter).toBe('string');
expect(response.body.currentId).toBeDefined();
expect(response.body.timer).toBeDefined();
expect(response.body.clock).toBeDefined();
expect(response.body.playback).toBeDefined();
expect(response.body.title).toBeDefined();
expect(response.body.presenter).toBeDefined();
});
});
@@ -1,7 +1,12 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
import { server, shutdown, startServer } from '../../app.js';
import { promise } from '../../modules/loadDb.js';
beforeAll(async () => {
await promise;
startServer();
});
beforeAll(() => startServer());
afterAll(() => shutdown());
describe('When a GET request request is sent', () => {
@@ -19,73 +24,73 @@ describe('When a GET request request is sent', () => {
});
describe('When a POST state change is sent', () => {
test('POST /playback/start returns 200', async () => {
test('POST /playback/start returns 202', async () => {
await supertest(server)
.post('/playback/start')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/pause returns 200', async () => {
test('POST /playback/pause returns 202', async () => {
await supertest(server)
.post('/playback/pause')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/stop returns 200', async () => {
test('POST /playback/stop returns 202', async () => {
await supertest(server)
.post('/playback/stop')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/roll returns 200', async () => {
test('POST /playback/roll returns 202', async () => {
await supertest(server)
.post('/playback/roll')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/previous returns 200', async () => {
test('POST /playback/previous returns 202', async () => {
await supertest(server)
.post('/playback/previous')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/next returns 200', async () => {
test('POST /playback/next returns 202', async () => {
await supertest(server)
.post('/playback/next')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/unload returns 200', async () => {
test('POST /playback/unload returns 202', async () => {
await supertest(server)
.post('/playback/unload')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
});
test('POST /playback/reload returns 200', async () => {
test('POST /playback/reload returns 202', async () => {
await supertest(server)
.post('/playback/reload')
.expect(200)
.expect(202)
.then((response) => {
expect(response.text.includes('<!doctype html>')).toBe(false);
});
@@ -100,24 +105,18 @@ describe('When a POST state change is sent', () => {
});
});
test('POST of unknown request returns 404', async () => {
await supertest(server).post('/playback/madeup').expect(404);
test('POST of unknown request returns 400', async () => {
await supertest(server).post('/playback/madeup').expect(400);
});
});
describe('Test POST requests with payload', () => {
describe('Start given event', () => {
test('POST /playback/start with correctly given ID', async () => {
await supertest(server)
.post('/playback/start')
.query({eventId: '5946'})
.expect(200);
await supertest(server).post('/playback/start').query({ eventId: '5946' }).expect(202);
});
test('POST /playback/start with correctly given index', async () => {
await supertest(server)
.post('/playback/start')
.query({eventIndex: '2'})
.expect(200);
await supertest(server).post('/playback/start').query({ eventIndex: '2' }).expect(202);
});
test('POST /playback/start with incorrectly given ID', async () => {
await supertest(server)
@@ -147,28 +146,16 @@ describe('Test POST requests with payload', () => {
describe('Load given event', () => {
test('POST /playback/load with correctly given ID', async () => {
await supertest(server)
.post('/playback/load')
.query({eventId: '5946'})
.expect(200);
await supertest(server).post('/playback/load').query({ eventId: '5946' }).expect(202);
});
test('POST /playback/load with correctly given index', async () => {
await supertest(server)
.post('/playback/load')
.query({eventIndex: '2'})
.expect(200);
await supertest(server).post('/playback/load').query({ eventIndex: '2' }).expect(202);
});
test('POST /playback/load with incorrectly given ID', async () => {
await supertest(server)
.post('/playback/load')
.query({eventId: 'doesntexist'})
.expect(400);
await supertest(server).post('/playback/load').query({ eventId: 'doesntexist' }).expect(400);
});
test('POST /playback/load with incorrectly given index', async () => {
await supertest(server)
.post('/playback/load')
.query({eventIndex: '25'})
.expect(400);
await supertest(server).post('/playback/load').query({ eventIndex: '25' }).expect(400);
});
test('POST /playback/load with incorrectly given index (NaN)', async () => {
await supertest(server)
@@ -1,7 +1,12 @@
import { server, shutdown, startServer } from '../../app.js';
import supertest from 'supertest';
import { promise } from '../../modules/loadDb.js';
beforeAll(async () => {
await promise;
startServer();
});
beforeAll(() => startServer());
afterAll(() => shutdown());
const testEvent = {
+2
View File
@@ -44,3 +44,5 @@ router.post('/unload', pbUnload);
// create route between controller and '/playback/reload' endpoint
router.post('/reload', pbReload);
// router.post('*', (req, res) => res.return(404))
+3 -2
View File
@@ -13,6 +13,7 @@ import {
paramsMustHaveEventId,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
} from '../controllers/rundownController.validate.js';
export const router = express.Router();
@@ -21,7 +22,7 @@ export const router = express.Router();
router.get('/', rundownGetAll);
// create route between controller and '/eventlist/:eventId' endpoint
router.get('/:eventId', getEventById);
router.get('/:eventId', paramsMustHaveEventId, getEventById);
// create route between controller and '/eventlist/' endpoint
router.post('/', rundownPostValidator, rundownPost);
@@ -30,7 +31,7 @@ router.post('/', rundownPostValidator, rundownPost);
router.put('/', rundownPutValidator, rundownPut);
// create route between controller and '/eventlist/reorder' endpoint
router.patch('/reorder/', rundownReorder);
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
// create route between controller and '/eventlist/applydelay/:eventId' endpoint
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
+4
View File
@@ -1,6 +1,10 @@
import { promise } from './modules/loadDb.js';
(async () => {
let loaded;
try {
await promise;
const { startServer, startOSCServer } = await import('./app.js');
// Start express server
loaded = await startServer();
+198
View File
@@ -0,0 +1,198 @@
/**
* starts loaded timer
*/
import { socketProvider } from '../classes/socket/SocketController.js';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
/**
* Service manages playback status of app
* Coordinating with necessary services
*/
export class PlaybackService {
/**
* makes calls for loading and starting given event
* @param {object} event
* @return {boolean} success
*/
static loadEvent(event) {
if (!event) {
socketProvider.error('PLAYBACK', 'No event found');
return false;
}
if (event.skip) {
socketProvider.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
return false;
}
global.timer.pause();
global.timer.loadEvent(event);
return true;
}
/**
* starts event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static startById(eventId) {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* starts an event at index
* @param {number} eventIndex
* @return {boolean} success
*/
static startByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
* @return {boolean} success
*/
static loadById(eventId) {
const event = EventLoader.getEventWithId(eventId);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* loads event matching given ID
* @param {number} eventIndex
* @return {boolean} success
*/
static loadByIndex(eventIndex) {
const event = EventLoader.getEventAtIndex(eventIndex);
const success = PlaybackService.loadEvent(event);
if (success) {
socketProvider.info('PLAYBACK', `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
static loadPrevious() {
const previousEvent = eventLoader.findPrevious();
if (previousEvent) {
PlaybackService.loadById(previousEvent.id);
global.timer.previous();
}
}
/**
* Loads event after currently selected
*/
static loadNext() {
const nextEvent = eventLoader.findNext();
if (nextEvent) {
PlaybackService.loadById(nextEvent.id);
global.timer.next();
}
}
/**
* Starts playback on selected event
*/
static start() {
if (!eventLoader.selectedEventId) {
return;
}
const newState = global.timer.start();
if (newState === 'start') {
socketProvider.info('PLAYBACK', 'Play Mode Start');
}
socketProvider.send('playstate', newState);
}
/**
* Pauses playback on selected event
*/
static pause() {
if (!eventLoader.selectedEventId) {
return;
}
const newState = global.timer.pause();
if (newState === 'pause') {
socketProvider.info('PLAYBACK', 'Play Mode Paused');
}
socketProvider.send('playstate', newState);
}
/**
* Stops timer and unloads any events
*/
static stop() {
if (!eventLoader.selectedEventId && global.timer.state !== 'roll') {
return;
}
eventLoader.reset();
const newState = global.timer.stop();
if (newState === 'stop') {
socketProvider.info('PLAYBACK', 'Play Mode Stopped');
}
socketProvider.send('playstate', newState);
}
/**
* Reloads current event
*/
static reload() {
if (!eventLoader.selectedEventId) {
return;
}
const newState = global.timer.reload();
socketProvider.info('PLAYBACK', 'Reloaded event');
socketProvider.send('playstate', newState);
}
/**
* Sets playback to roll
*/
static roll() {
if (!EventLoader.getNumEvents()) {
return;
}
if (global.timer.state === 'roll') {
return;
}
const newState = global.timer.roll();
if (newState === 'roll') {
socketProvider.info('PLAYBACK', 'Play Mode Roll');
}
socketProvider.send('playstate', newState);
}
/**
* Adds delay to current event
* @param {number} delayTime time in ms
*/
static setDelay(delayTime) {
if (!eventLoader.selectedEventId) {
return;
}
const delayInMs = delayTime * 1000 * 60;
global.timer.increment(delayInMs);
socketProvider.info('PLAYBACK', `Added ${delayTime} min delay`);
}
}
+250
View File
@@ -0,0 +1,250 @@
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { generateId } from '../utils/generate_id.js';
import {
block as blockDef,
delay as delayDef,
event as eventDef,
} from '../models/eventsDefinition.js';
import { MAX_EVENTS } from '../settings.js';
import { eventLoader } from '../classes/event-loader/EventLoader.js';
const affectedLoaded = (affectedIds) => {
const now = eventLoader.selectedEventId;
const nowPublic = eventLoader.selectedPublicEventId;
const next = eventLoader.nextEventId;
const nextPublic = eventLoader.nextPublicEventId;
return (
affectedIds.includes(now) ||
affectedIds.includes(now) ||
affectedIds.includes(nowPublic) ||
affectedIds.includes(next) ||
affectedIds.includes(nextPublic)
);
};
const isNewNext = () => {
const timedEvents = getTimedEvents();
const now = eventLoader.selectedEventId;
const next = eventLoader.nextEventId;
// check whether the index of now and next are consecutive
const indexNow = timedEvents.findIndex((event) => event.id === now);
const indexNext = timedEvents.findIndex((event) => event.id === next);
if (indexNext - indexNow !== 1) {
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;
let foundNew = false;
let isAfter = false;
for (const event of timedEvents) {
if (!isAfter) {
if (event.id === nowPublic) {
isAfter = true;
}
} else {
if (event.id === nextPublic) {
break;
}
if (event.isPublic) {
foundNew = true;
break;
}
}
}
return foundNew;
};
/**
* updates timer object
* @param {array} [affectedIds]
*/
export function updateTimer(affectedIds) {
const runningEventId = eventLoader.selectedEventId;
if (runningEventId === null) {
return false;
}
// we need to reload in a few scenarios:
// 1. we are not confident that changes do not affect running event
// 2. the edited event is currently being used (now or next)
// 3. the edited event replaces one of the previous (next)
if (typeof affectedIds === 'undefined') {
global.timer.syncLoaded(runningEventId);
return true;
}
if (affectedLoaded(affectedIds)) {
global.timer.syncLoaded(runningEventId);
return true;
}
if (isNewNext()) {
global.timer.syncLoaded(runningEventId);
return true;
}
return false;
}
/**
* @description returns all events of type event
* @return {unknown[]}
*/
export function getTimedEvents() {
// return data.events.filter((e) => e.type === 'event');
const rundown = DataProvider.getRundown();
return rundown.filter((e) => e.type === 'event');
}
/**
* @description creates a new event with given data
* @param {object} eventData
* @return {unknown[]}
*/
export async function addEvent(eventData) {
const numEvents = DataProvider.getNumEvents();
if (numEvents > MAX_EVENTS) {
throw new Error(`ERROR: Reached limit number of ${MAX_EVENTS} events`);
}
let newEvent = {};
const id = generateId();
switch (eventData.type) {
case 'event':
newEvent = { ...eventDef, ...eventData, id };
break;
case 'delay':
newEvent = { ...delayDef, ...eventData, id };
break;
case 'block':
newEvent = { ...blockDef, ...eventData, id };
break;
}
try {
const afterId = newEvent?.after;
if (typeof afterId === 'undefined') {
await DataProvider.insertEventAt(newEvent, 0);
} else {
delete newEvent.after;
await DataProvider.insertEventAfterId(newEvent, afterId);
}
} catch (error) {
throw new Error(error);
}
updateTimer([id]);
return newEvent;
}
export async function editEvent(eventData) {
const eventId = eventData.id;
const eventInMemory = DataProvider.getEventById(eventId);
if (typeof eventInMemory === 'undefined') {
throw new Error('No event with ID found');
}
const newEvent = await DataProvider.updateEventById(eventId, eventData);
updateTimer([eventId]);
return newEvent;
}
/**
* deletes event by its ID
* @param eventId
* @returns {Promise<void>}
*/
export async function deleteEvent(eventId) {
await DataProvider.deleteEvent(eventId);
updateTimer([eventId]);
}
/**
* deletes all events in database
* @returns {Promise<void>}
*/
export async function deleteAllEvents() {
await DataProvider.clearRundown();
updateTimer();
}
/**
* reorders a given event
* @param {string} eventId
* @param {number} from
* @param {number} to
* @returns {Promise<void>}
*/
export async function reorderEvent(eventId, from, to) {
const rundown = DataProvider.getRundown();
const index = rundown.findIndex((event) => event.id === eventId);
if (index !== from) {
throw new Error('ID not found at index');
}
const [reorderedItem] = rundown.splice(from, 1);
// reinsert item at to
rundown.splice(to, 0, reorderedItem);
// save rundown
await DataProvider.setEventData(rundown);
updateTimer();
return reorderedItem;
}
/**
* applies delay value for given event
* @param eventId
* @returns {Promise<void>}
*/
export async function applyDelay(eventId) {
const rundown = DataProvider.getRundown();
// AUX
let delayIndex = null;
let blockIndex = null;
let delayValue = 0;
for (const [index, e] of rundown.entries()) {
// look for delay
if (delayIndex === null) {
if (e.id === eventId && e.type === 'delay') {
delayValue = e.duration;
delayIndex = index;
}
}
// apply delay value to all items until block or end
else {
if (e.type === 'event') {
// update times
e.timeStart += delayValue;
e.timeEnd += delayValue;
// increment revision
e.revision += 1;
} else if (e.type === 'block') {
// save id and stop
blockIndex = index;
break;
}
}
}
if (delayIndex === null) {
throw new Error(`Delay event with ID ${eventId} not found`);
}
// delete delay
rundown.splice(delayIndex, 1);
// delete block
// index would have moved down since we deleted delay
if (blockIndex) rundown.splice(blockIndex - 1, 1);
// update rundown
await DataProvider.setRundown(rundown);
updateTimer();
}
+1 -11
View File
@@ -4,16 +4,6 @@ import { isStringEmpty, parseExcel, parseJson, validateEvent } from '../parser.j
import { makeString, validateDuration } from '../parserUtils.js';
import { parseAliases, parseUserFields, parseViews } from '../parserFunctions.js';
describe('refuses import of old / unknown versions', () => {
test('a v1 file', () => {
const testFile = {
settings: {
version: 1,
},
};
});
});
describe('test json parser with valid def', () => {
const testData = {
rundown: [
@@ -499,7 +489,7 @@ describe('test event validator', () => {
user7: expect.any(String),
user8: expect.any(String),
user9: expect.any(String),
}),
})
);
});
-6
View File
@@ -2350,13 +2350,11 @@ const object = [
'specialist',
'stroke',
'switch',
'trash',
'tune',
'zone',
'anger',
'award',
'bid',
'bitter',
'boot',
'bug',
'camp',
@@ -2579,8 +2577,6 @@ const object = [
'touch',
'cancel',
'chemical',
'cry',
'dump',
'extreme',
'push',
'conflict',
@@ -2596,7 +2592,6 @@ const object = [
'total',
'treat',
'vast',
'abuse',
'beat',
'burn',
'deposit',
@@ -2668,7 +2663,6 @@ const object = [
'rough',
'sad',
'scratch',
'sick',
'strike',
'employ',
'external',