From 80c2e9a8ba71b2496ebe46c75ed2717c269ee945 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Mon, 13 Dec 2021 21:29:56 +0100 Subject: [PATCH] Feat/studio clock (#60) --- README.md | 46 +- client/package.json | 5 +- client/src/App.jsx | 19 +- client/src/assets/fonts/digital-7.mono.ttf | Bin 0 -> 34404 bytes client/src/common/components/nav/NavLogo.jsx | 27 +- ...NavLogo.module.css => NavLogo.module.scss} | 4 + .../utils/__tests__/eventsManager.test.js | 410 ++++++++++++++++++ client/src/common/utils/dateConfig.js | 6 +- client/src/common/utils/eventsManager.js | 84 ++++ .../src/features/control/MessageControl.jsx | 100 +++-- .../control/MessageControl.module.css | 32 -- .../control/MessageControl.module.scss | 54 +++ client/src/features/viewers/ViewWrapper.jsx | 19 +- .../viewers/backstage/StageManager.jsx | 19 +- .../viewers/presenter/PresenterView.jsx | 2 +- .../viewers/production/lower/LowerClean.jsx | 4 + .../viewers/production/lower/LowerLines.jsx | 4 + .../features/viewers/studio/StudioClock.jsx | 87 ++++ .../viewers/studio/StudioClock.module.scss | 190 ++++++++ client/yarn.lock | 59 ++- server/package.json | 2 +- server/src/classes/EventTimer.js | 29 +- server/src/classes/Timer.js | 4 +- server/src/controllers/OscController.js | 6 + server/src/controllers/playbackController.js | 16 + server/src/routes/playbackRouter.js | 6 + 26 files changed, 1099 insertions(+), 135 deletions(-) create mode 100644 client/src/assets/fonts/digital-7.mono.ttf rename client/src/common/components/nav/{NavLogo.module.css => NavLogo.module.scss} (87%) create mode 100644 client/src/common/utils/__tests__/eventsManager.test.js create mode 100644 client/src/common/utils/eventsManager.js delete mode 100644 client/src/features/control/MessageControl.module.css create mode 100644 client/src/features/control/MessageControl.module.scss create mode 100644 client/src/features/viewers/studio/StudioClock.jsx create mode 100644 client/src/features/viewers/studio/StudioClock.module.scss diff --git a/README.md b/README.md index 1bc3cc8ac..1c5e7667f 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,18 @@ Once installed and running, ontime starts a background server that is the heart The app, is used to add / edit your running order in the event list, and running the timers using the Playback Control function. From here, any device in the same network with a browser is able to render the views as described. This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001` or `192.168.1.3:4001` -You can then use the the ontime logo on the top right corner to select the desired view. +You can then use the ontime logo in the top right corner to select the desired view (event in the lower thirds view, where it is hidden). -In case of unnatended machines or automations, it is possible to use different URL to recall individual views +In case of unattended machines or automations, it is possible to use different URL to recall individual views ``` -IP.ADDRESS:4001 > Web server default to stage timer view -IP.ADDRESS:4001/speaker > Speaker / Stage timer view +IP.ADDRESS:4001 > Web server default to presenter timer view +IP.ADDRESS:4001/preseter > Presenter / Stage timer view IP.ADDRESS:4001/sm > Stage Manager / Backstage view IP.ADDRESS:4001/public > Public / Foyer view IP.ADDRESS:4001/pip > Picture in Picture view IP.ADDRESS:4001/lower > Lower Thirds +IP.ADDRESS:4001/studio > Studio Clock ``` More documentation available [here](https://cpvalente.gitbook.io/ontime/) @@ -43,43 +44,54 @@ More documentation available [here](https://cpvalente.gitbook.io/ontime/) - [x] Send live messages to different screen types - [x] Ability to differentiate between backstage and public data - [x] Manage delays workflow -- [x] OSC Control and Feedback +- [x] Open Sound Control (OSC) Control and Feedback - [x] Roll mode: run independently using the system clock +- [x] Import event list from Excel ## Unopinionated -We are not interested in forcing workflows and have made ontime so it is flexible to whichever way you would like to work. +We are not interested in forcing workflows and have made ontime, so it is flexible to whichever way you would like to work. - [x] You do not need an order list to use the timer. Create an empty event and the OSC API works just the same - [x] If you want just the info screens, no need to use the timer! -- [x] Dont have or care for a schedule? +- [x] Don't have or care for a schedule? - [x] a single event with no data is enough to use the OSC API and get going - - [x] use the order list to create a set of quick timers by setting the beggining and start times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quick recall this with OSC as always + - [x] use the order list to create a set of quick timers by setting the beginning and start times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quickly recall this with OSC as always + +## Integrations and Workflow +The app is being currently developed to a wide user base, from broadcast to entertainment and conference halls. + +Taking advantage of the integrations in Ontime, we currently use Ontime with: +- `disguise`: trigger ontime from d3's timeline using the **OSC API**, **render views** using d3's webmodule +- `OBS`: **render views** using the Browser Module +- `QLab`: trigger ontime using **OSC API** +- `Companion`: trigger ontime and manipulate timer using **OSC API** + ## Roadmap -### For version 1 -Almost reaching a feature set that we can call v1. Before that: -- [ ] Mac OS version -- [ ] Finish Documentation -### Continuing +### Continued development +There are several features planned in the roadmap. +These will be implemented in a development friendly order unless there is user demand to bump any of them. - [ ] Linux version - [ ] Headless version (run server only anywhere, configure from a browser locally) -- [ ] Companion integration +- [ ] Companion module - [ ] Lower Third Manager - [ ] Note only event - [ ] URL Aliases (define configurable aliases to ease onsite setup) - [ ] Logging view - [ ] Reach Schedule: way to speedup timer to meet a deadline -- [ ] Excel Import - [ ] vMix integration +### For version 1 +Almost reaching a feature set that we can call v1. Before that: +- [ ] Mac OS version + ### Issues The app is still in pre-release and there are a few issues, mainly concerning style. This will be receiving attention as we near v1 release #### Style -- [ ] App appears visually broken: Please ensure that windows settings have no display zoom (it is 125% by default) -- [ ] app needs improvement on handling zoomed interfaces +- [ ] App needs improvement on handling zoomed interfaces: Please ensure that windows settings have no display zoom (it is 125% by default) - [ ] Very long titles might cause interface to shift # Help diff --git a/client/package.json b/client/package.json index d1d214bae..6d9f85bc9 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "react-scripts": "4.0.3", "socket.io-client": "^4.3.2", "typeface-open-sans": "^1.1.13", + "use-fit-text": "^2.4.0", "web-vitals": "^1.0.1" }, "scripts": { @@ -50,5 +51,7 @@ "last 1 safari version" ] }, - "devDependencies": {} + "devDependencies": { + "sass": "^1.44.0" + } } diff --git a/client/src/App.jsx b/client/src/App.jsx index b3af1e576..a56c126de 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -22,6 +22,8 @@ const Lower = lazy(() => ); const Pip = lazy(() => import('features/viewers/production/Pip')); +const StudioClock = lazy(() => import('features/viewers/studio/StudioClock')); + const queryClient = new QueryClient(); // Seemed to cause issues // broadcastQueryClient({ @@ -29,12 +31,13 @@ const queryClient = new QueryClient(); // broadcastChannel: 'ontime', // }); -const SSpeaker = withSocket(PresenterView); -const SSpeakerSimple = withSocket(PresenterSimple); +const SPresenter = withSocket(PresenterView); +const SPresenterSimple = withSocket(PresenterSimple); const SStageManager = withSocket(StageManager); const SPublic = withSocket(Public); const SLowerThird = withSocket(Lower); const SPip = withSocket(Pip); +const SStudio = withSocket(StudioClock); function App() { // Handle keyboard shortcuts @@ -69,18 +72,20 @@ function App() { - + - - - + + + + + {/* Lower cannot have fallback */} {/* Send to default if nothing found */} - + diff --git a/client/src/assets/fonts/digital-7.mono.ttf b/client/src/assets/fonts/digital-7.mono.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a481b97b4f84fd4199360c511517a5cf22d2a664 GIT binary patch literal 34404 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 { @@ -30,9 +32,10 @@ export default function NavLogo() { }; }, [handleKeyPress]); + const baseOpacity = (isHidden) ? 0 : 0.5 return ( @@ -51,12 +54,12 @@ export default function NavLogo() { className={showNav ? style.nav : style.navHidden} > } + onKeyDownCapture={() => } > - Speaker + Presenter PIP + } + > + Studio Clock + )} ); } + +NavLogo.propTypes = { + isHidden: PropTypes.bool, +} diff --git a/client/src/common/components/nav/NavLogo.module.css b/client/src/common/components/nav/NavLogo.module.scss similarity index 87% rename from client/src/common/components/nav/NavLogo.module.css rename to client/src/common/components/nav/NavLogo.module.scss index ad75df5b4..3f594bfe9 100644 --- a/client/src/common/components/nav/NavLogo.module.css +++ b/client/src/common/components/nav/NavLogo.module.scss @@ -1,6 +1,9 @@ +$nav-color: #fff; + .navContainer { position: absolute; right: 2vw; + top: 1vw; display: flex; flex-direction: column; align-items: flex-end; @@ -23,4 +26,5 @@ background-color: rgba(0, 0, 0, 0.7); padding: 0.5vh 1vw; border-radius: 4px; + color: $nav-color; } diff --git a/client/src/common/utils/__tests__/eventsManager.test.js b/client/src/common/utils/__tests__/eventsManager.test.js new file mode 100644 index 000000000..68e26c91c --- /dev/null +++ b/client/src/common/utils/__tests__/eventsManager.test.js @@ -0,0 +1,410 @@ +import {formatEventList, getEventsWithDelay, trimEventlist} from "../eventsManager"; + +test('getEventsWithDelay function', () => { + + const testData = [ + { + "title": "Welcome to Ontime", + "timeStart": 28800000, + "timeEnd": 30600000, + "type": "event", + "id": "5946" + }, + { + "duration": 60000, + "type": "delay", + "id": "24240" + }, + { + "title": "Unless recalled by the OSC address", + "timeStart": 34920000, + "timeEnd": 35520000, + "type": "event", + "id": "8ee5" + }, + { + "title": "Use simpler times to create a timer", + "timeStart": 120000, + "timeEnd": 720000, + "type": "event", + "id": "8222" + }, + { + "duration": 900000, + "type": "delay", + "revision": 0, + "id": "a386" + }, + { + "title": "Add delay blocks to affect all events", + "timeStart": 37320000, + "timeEnd": 38520000, + "type": "event", + "id": "6dce" + }, + { + "title": "Add and remove events with [+] and [-]", + "timeStart": 38520000, + "timeEnd": 45120000, + "type": "event", + "id": "2651" + }, + { + "type": "block", + "id": "e6a1" + }, + { + "title": "And control whether they are public", + "timeStart": 46800000, + "timeEnd": 57600000, + "type": "event", + "id": "1358" + } + ]; + + const expected = [ + { + "title": "Welcome to Ontime", + "timeStart": 28800000, + "timeEnd": 30600000, + "type": "event", + "id": "5946" + }, + { + "title": "Unless recalled by the OSC address", + "timeStart": 34920000+60000, + "timeEnd": 35520000+60000, + "type": "event", + "id": "8ee5" + }, + { + "title": "Use simpler times to create a timer", + "timeStart": 120000+60000, + "timeEnd": 720000+60000, + "type": "event", + "id": "8222" + }, + { + "title": "Add delay blocks to affect all events", + "timeStart": 37320000+60000+900000, + "timeEnd": 38520000+60000+900000, + "type": "event", + "id": "6dce" + }, + { + "title": "Add and remove events with [+] and [-]", + "timeStart": 38520000+60000+900000, + "timeEnd": 45120000+60000+900000, + "type": "event", + "id": "2651" + }, + { + "title": "And control whether they are public", + "timeStart": 46800000, + "timeEnd": 57600000, + "type": "event", + "id": "1358" + } + ] + + expect(getEventsWithDelay(testData)).toStrictEqual(expected); +}); + +describe('getEventsWithDelay edge cases', () => { + + test('given an empty array', () => { + const emptyArray = { + test: [], + expect: [], + } + + expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect); + }); + + test('given an undefined object', () => { + const withUndefined = { + test: undefined, + expect: [], + } + + expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect); + }); + + test('given a corrupted event object', () => { + const testData = [ + { + "title": "Welcome to Ontime", + "timeEnd": 30600000, + "type": "event", + "id": "5946" + }, + { + "duration": 60000, + "type": "delay", + "id": "24240" + }, + { + "title": "Unless recalled by the OSC address", + "timeStart": 34920000, + "timeEnd": 35520000, + "type": "event", + "id": "8ee5" + } + ]; + const expected = [ + { + "title": "Welcome to Ontime", + "timeEnd": 30600000, + "type": "event", + "id": "5946" + }, + { + "title": "Unless recalled by the OSC address", + "timeStart": 34920000+60000, + "timeEnd": 35520000+60000, + "type": "event", + "id": "8ee5" + } + ]; + + expect(getEventsWithDelay(testData)).toStrictEqual(expected); + }); + + test('given a corrupted delay object', () => { + const testData = [ + { + "title": "Welcome to Ontime", + "timeStart": 28800000, + "timeEnd": 30600000, + "type": "event", + "id": "5946" + }, + { + "type": "delay", + "id": "24240" + }, + { + "title": "Unless recalled by the OSC address", + "timeStart": 34920000, + "timeEnd": 35520000, + "type": "event", + "id": "8ee5" + } + ]; + const expected = [ + { + "title": "Welcome to Ontime", + "timeStart": 28800000, + "timeEnd": 30600000, + "type": "event", + "id": "5946" + }, + { + "title": "Unless recalled by the OSC address", + "timeStart": 34920000, + "timeEnd": 35520000, + "type": "event", + "id": "8ee5" + } + ]; + + expect(getEventsWithDelay(testData)).toStrictEqual(expected); + }); +}); + +describe('test trimEventlist function', () => { + + const limit = 8; + const testData = [ + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'}, + {id: '5'}, + {id: '6'}, + {id: '7'}, + {id: '8'}, + {id: '9'}, + {id: '10'}, + {id: '11'}, + {id: '12'}, + ]; + + + test('when we use the first item', () => { + const selectedId = '1'; + const expected = [ + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'}, + {id: '5'}, + {id: '6'}, + {id: '7'}, + {id: '8'}, + ]; + + const l = trimEventlist(testData, selectedId, limit); + expect(l.length).toBe(limit); + expect(l).toStrictEqual(expected); + }); + + test('when we use the third item', () => { + const selectedId = '3'; + const expected = [ + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'}, + {id: '5'}, + {id: '6'}, + {id: '7'}, + {id: '8'} + ]; + + const l = trimEventlist(testData, selectedId, limit); + expect(l.length).toBe(limit); + expect(l).toStrictEqual(expected); + }); + + test('when we use the fourth item', () => { + const selectedId = '4'; + const expected = [ + {id: '2'}, + {id: '3'}, + {id: '4'}, + {id: '5'}, + {id: '6'}, + {id: '7'}, + {id: '8'}, + {id: '9'} + ]; + + const l = trimEventlist(testData, selectedId, limit); + expect(l.length).toBe(limit); + expect(l).toStrictEqual(expected); + }); + + test('if selected is not found', () => { + const selectedId = '15'; + const expected = [ + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'}, + {id: '5'}, + {id: '6'}, + {id: '7'}, + {id: '8'}, + ]; + + const l = trimEventlist(testData, selectedId, limit); + expect(l.length).toBe(limit); + expect(l).toStrictEqual(expected); + }); +}); + +describe('test formatEvents function', () => { + const testEvent = [ + { + "title": "Welcome to Ontime", + "subtitle": "Subtitles are useful", + "presenter": "cpvalente", + "note": "Maybe a running note for the operator?", + "timeStart": 28800000, + "timeEnd": 30600000, + "isPublic": false, + "type": "event", + "revision": 0, + "id": "5946" + }, + { + "title": "Unless recalled by the OSC address", + "subtitle": "", + "presenter": "", + "note": "In green, below", + "timeStart": 34800000, + "timeEnd": 35400000, + "isPublic": false, + "type": "event", + "revision": 0, + "id": "8ee5" + } + ]; + + test ('it parses correctly', () => { + const selectedId = 'otherEvent'; + const nextId = 'notHere'; + const expected = [ + { + id: '5946', + time: '08:00 - 08:30', + title: 'Welcome to Ontime', + isNow: false, + isNext: false, + }, + { + id: '8ee5', + time: '09:40 - 09:50', + title: 'Unless recalled by the OSC address', + isNow: false, + isNext: false, + }, + + ] + + const parsed = formatEventList(testEvent, selectedId, nextId, true); + expect(parsed).toStrictEqual(expected); + }); + + test ('it handles selected correctly', () => { + const selectedId = '5946'; + const nextId = '8ee5'; + const expected = [ + { + id: '5946', + time: '08:00 - 08:30', + title: 'Welcome to Ontime', + isNow: true, + isNext: false, + }, + { + id: '8ee5', + time: '09:40 - 09:50', + title: 'Unless recalled by the OSC address', + isNow: false, + isNext: true, + }, + + ] + + const parsed = formatEventList(testEvent, selectedId, nextId,true); + expect(parsed).toStrictEqual(expected); + }); + + test ('it handles next correctly', () => { + const selectedId = '8ee5'; + const nextId = 'notHere'; + + const expected = [ + { + id: '5946', + time: '08:00 - 08:30', + title: 'Welcome to Ontime', + isNow: false, + isNext: false, + }, + { + id: '8ee5', + time: '09:40 - 09:50', + title: 'Unless recalled by the OSC address', + isNow: true, + isNext: false, + }, + + ] + + const parsed = formatEventList(testEvent, selectedId, nextId, true); + expect(parsed).toStrictEqual(expected); + }); +}) \ No newline at end of file diff --git a/client/src/common/utils/dateConfig.js b/client/src/common/utils/dateConfig.js index 0c95fff74..3bb3af543 100644 --- a/client/src/common/utils/dateConfig.js +++ b/client/src/common/utils/dateConfig.js @@ -40,11 +40,11 @@ export const stringFromMillis = ( * another go at simpler string formatting (counters) * @description Converts seconds to string representing time * @param {number} seconds - time in seconds - * @param {boolean} hideZero - wether to show hours in case its 00 + * @param {boolean} [hideZero] - whether to show hours in case its 00 * @returns {string} String representing absolute time 00:12:02 */ -export function formatDisplay(seconds, hideZero) { +export function formatDisplay(seconds, hideZero=false) { // add an extra 0 if necessary const format = (val) => `0${Math.floor(val)}`.slice(-2); @@ -59,7 +59,6 @@ export function formatDisplay(seconds, hideZero) { /** * @description Converts milliseconds to seconds * @param {number} millis - time in seconds - * @param {boolean} hideZero - wether to show hours in case its 00 * @returns {number} Amount in seconds */ @@ -71,7 +70,6 @@ export const millisToSeconds = (millis) => { /** * @description Converts milliseconds to seconds * @param {number} millis - time in seconds - * @param {boolean} hideZero - wether to show hours in case its 00 * @returns {number} Amount in seconds */ diff --git a/client/src/common/utils/eventsManager.js b/client/src/common/utils/eventsManager.js new file mode 100644 index 000000000..b00db9df4 --- /dev/null +++ b/client/src/common/utils/eventsManager.js @@ -0,0 +1,84 @@ +/** + * @description From a list of events, returns only events of type event with calculated delays + * @param {Object[]} events - given events + * @returns {Object[]} Filtered events with calculated delays + */ +import {stringFromMillis} from "./dateConfig"; + +export const getEventsWithDelay = (events) => { + + if (events == null) return []; + + const unfilteredEvents = [...events]; + + // Add running delay + let delay = 0; + for (const e of unfilteredEvents) { + if (e.type === 'block') delay = 0; + else if (e.type === 'delay') delay = delay + e.duration; + else if (e.type === 'event' && delay > 0) { + e.timeStart += delay; + e.timeEnd += delay; + } + } + + // filter just events + return unfilteredEvents.filter((e) => e.type === 'event'); +}; + +/** + * @description Returns trimmed event list array + * @param {Object[]} events - given events + * @param {string} selectedId - id of currently selected event + * @param {number} limit - max number of events to return + * @returns {Object[]} Event list with maximum objects + */ +export const trimEventlist = (events, selectedId, limit) => { + if (events == null) return []; + + const BEFORE = 2; + const trimmedEvents = [...events]; + + // limit events length if necessary + if (limit != null) { + while (trimmedEvents.length > limit) { + const idx = trimmedEvents.findIndex((e) => e.id === selectedId); + if (idx <= BEFORE) { trimmedEvents.pop(); } + else { trimmedEvents.shift(); } + } + } + return trimmedEvents; +}; + +/** + * @description Returns list of events formatted to be displayed + * @param {Object[]} events - given events + * @param {string} selectedId - id of currently selected event + * @param {string} nextId - id of next event + * @param {boolean} [showEnd] - whether to show the end time + * @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}] + */ +export const formatEventList = (events, selectedId, nextId, showEnd = false) => { + if (events == null) return []; + + const givenEvents = [...events]; + + // format list + let formattedEvents = []; + for (const g of givenEvents) { + const start = stringFromMillis(g.timeStart, false); + const end = stringFromMillis(g.timeEnd, false); + + formattedEvents.push({ + id: g.id, + time: showEnd ? `${start} - ${end}` : start, + title: g.title, + isNow: g.id === selectedId, + isNext: g.id === nextId, + }); + } + + return formattedEvents; +}; + + diff --git a/client/src/features/control/MessageControl.jsx b/client/src/features/control/MessageControl.jsx index e881cb5f1..264c4c5f8 100644 --- a/client/src/features/control/MessageControl.jsx +++ b/client/src/features/control/MessageControl.jsx @@ -1,15 +1,16 @@ -import { Editable, EditableInput, EditablePreview } from '@chakra-ui/editable'; -import { useEffect, useState } from 'react'; -import { useSocket } from 'app/context/socketContext'; +import {Editable, EditableInput, EditablePreview} from '@chakra-ui/editable'; +import {Switch} from "@chakra-ui/react"; +import {useEffect, useState} from 'react'; +import {useSocket} from 'app/context/socketContext'; import VisibleIconBtn from 'common/components/buttons/VisibleIconBtn'; -import style from './MessageControl.module.css'; +import style from './MessageControl.module.scss'; const inputProps = { size: 'sm', }; const InputRow = (props) => { - const { label, placeholder, text, visible } = props; + const {label, placeholder, text, visible} = props; return ( <> @@ -22,8 +23,8 @@ const InputRow = (props) => { className={style.inline} color={text === '' ? '#666' : 'inherit'} > - - + + { if (socket == null) return; // Handle presenter messages socket.on('messages-presenter', (data) => { - setPres({ ...data }); + setPres({...data}); }); // Handle public messages socket.on('messages-public', (data) => { - setPubl({ ...data }); + setPubl({...data}); }); // Handle lower third messages socket.on('messages-lower', (data) => { - setLower({ ...data }); + setLower({...data}); + }); + + // Handle lower third messages + socket.on('onAir', (data) => { + setonAir(data); }); // Ask for up to date data socket.emit('get-messages'); + // Ask for onAir state + socket.emit('get-onAir'); // Clear listeners return () => { socket.off('messages-public'); socket.off('messages-presenter'); socket.off('messages-lower'); + socket.off('onAir'); }; }, [socket]); @@ -99,38 +109,54 @@ export default function MessageControl() { case 'toggle-lower-visible': socket.emit('set-lower-visible', !lower.visible); break; - + case 'toggle-onAir': + socket.emit('set-onAir', !onAir); + break; default: break; } }; return ( -
- messageControl('pres-text', event)} - actionHandler={() => messageControl('toggle-pres-visible')} - /> - messageControl('publ-text', event)} - actionHandler={() => messageControl('toggle-publ-visible')} - /> - messageControl('lower-text', event)} - actionHandler={() => messageControl('toggle-lower-visible')} - /> -
+ <> +
+ messageControl('pres-text', event)} + actionHandler={() => messageControl('toggle-pres-visible')} + /> + messageControl('publ-text', event)} + actionHandler={() => messageControl('toggle-publ-visible')} + /> + messageControl('lower-text', event)} + actionHandler={() => messageControl('toggle-lower-visible')} + /> +
+
+ messageControl('toggle-onAir')}> + On Air? + + + {`/ontime/offAir << OSC >> /ontime/onAir`} + +
+ ); } diff --git a/client/src/features/control/MessageControl.module.css b/client/src/features/control/MessageControl.module.css deleted file mode 100644 index 6c67eeb37..000000000 --- a/client/src/features/control/MessageControl.module.css +++ /dev/null @@ -1,32 +0,0 @@ -.messageContainer { - background-color: rgba(0, 0, 0, 0.05); - border: 1px solid rgba(0, 0, 0, 0.05); - border-radius: 4px; - display: flex; - flex-direction: column; - gap: 0.5em; - padding: 0.5em; -} - -.inputItems { - display: grid; - grid-template-columns: 1fr auto; - gap: 1em; -} - -.label { - padding: 0; - margin: 0; - font-size: 0.9em; - color: #ccc; -} - -.inline { - border-radius: 4px; - background-color: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.05); -} - -.padleft { - padding-left: 0.5em; -} diff --git a/client/src/features/control/MessageControl.module.scss b/client/src/features/control/MessageControl.module.scss new file mode 100644 index 000000000..52889d3fb --- /dev/null +++ b/client/src/features/control/MessageControl.module.scss @@ -0,0 +1,54 @@ +.messageContainer, +.onAirToggle { + background-color: rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.05); + border-radius: 4px; + display: flex; + gap: 0.5em; + padding: 0.5em; + +} + +.messageContainer { + flex-direction: column; + + .inputItems { + display: grid; + grid-template-columns: 1fr auto; + gap: 1em; + } + .label { + padding: 0; + margin: 0; + font-size: 0.9em; + color: #ccc; + } + .inline { + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.05); + } + .padleft { + padding-left: 0.5em; + } +} + +.onAirToggle { + margin-top: 1em; + display: flex; + gap: 1em; + align-items: center; + line-height: 3em; + + .onAirLabel { + font-size: 1.2em; + } + .oscLabel { + color: #4bffabcc; + font-size: 0.8em; + float: right; + padding-right: 1em; + -webkit-user-select: text; + user-select: text; + } +} diff --git a/client/src/features/viewers/ViewWrapper.jsx b/client/src/features/viewers/ViewWrapper.jsx index 6d90c0a7e..7f58dea19 100644 --- a/client/src/features/viewers/ViewWrapper.jsx +++ b/client/src/features/viewers/ViewWrapper.jsx @@ -10,13 +10,9 @@ const withSocket = (Component) => { const WrappedComponent = (props) => { const { data: eventsData, - status: eventsDataStatus, - isError: eventsDataIsError, } = useFetch(EVENTS_TABLE, fetchAllEvents); const { data: genData, - status: genDataStatus, - isError: genDataIsError, } = useFetch(EVENT_TABLE, fetchEvent); const [publicEvents, setPublicEvents] = useState([]); @@ -58,6 +54,7 @@ const withSocket = (Component) => { presenterNext: '', }); const [selectedId, setSelectedId] = useState(null); + const [nextId, setNextId] = useState(null); const [publicSelectedId, setPublicSelectedId] = useState(null); const [general, setGeneral] = useState({ title: '', @@ -67,6 +64,7 @@ const withSocket = (Component) => { endMessage: '', }); const [playback, setPlayback] = useState(null); + const [onAir, setOnAir] = useState(false); // Ask for update on load useEffect(() => { @@ -96,6 +94,9 @@ const withSocket = (Component) => { socket.on('playstate', (data) => { setPlayback(data); }); + socket.on('onAir', (data) => { + setOnAir(data); + }); // Handle titles socket.on('titles', (data) => { @@ -112,6 +113,9 @@ const withSocket = (Component) => { socket.on('publicselected-id', (data) => { setPublicSelectedId(data); }); + socket.on('next-id', (data) => { + setNextId(data); + }); // Ask for up to date data socket.emit('get-messages'); @@ -124,6 +128,7 @@ const withSocket = (Component) => { // ask for playstate socket.emit('get-playstate'); + socket.emit('get-onAir') // Ask for up titles socket.emit('get-titles'); @@ -131,6 +136,7 @@ const withSocket = (Component) => { // Ask for up selected socket.emit('get-selected-id'); + socket.emit('get-next-id'); // Clear listeners return () => { @@ -139,9 +145,11 @@ const withSocket = (Component) => { socket.off('messages-lower'); socket.off('timer'); socket.off('playstate'); + socket.off('onAir'); socket.off('titles'); socket.off('publictitles'); socket.off('selected-id'); + socket.emit('next-id'); }; }, [socket]); @@ -224,6 +232,7 @@ const withSocket = (Component) => { ...timer, finished: playback === 'start' && timer.running <= 0 && timer.startedAt, clock: stringFromMillis(timer.clock), + clockNoSeconds: stringFromMillis(timer.clock, false), playstate: playback, }; @@ -240,7 +249,9 @@ const withSocket = (Component) => { backstageEvents={backstageEvents} selectedId={selectedId} publicSelectedId={publicSelectedId} + nextId={nextId} general={general} + onAir={onAir} /> ); }; diff --git a/client/src/features/viewers/backstage/StageManager.jsx b/client/src/features/viewers/backstage/StageManager.jsx index 3e843b2a1..39d507a83 100644 --- a/client/src/features/viewers/backstage/StageManager.jsx +++ b/client/src/features/viewers/backstage/StageManager.jsx @@ -6,6 +6,7 @@ import NavLogo from 'common/components/nav/NavLogo'; import { useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import TitleSide from 'common/components/views/TitleSide'; +import {getEventsWithDelay} from "../../../common/utils/eventsManager"; export default function StageManager(props) { const { publ, title, time, backstageEvents, selectedId, general } = props; @@ -20,23 +21,9 @@ export default function StageManager(props) { useEffect(() => { if (backstageEvents == null) return; - let events = [...backstageEvents]; + setFilteredEvents(getEventsWithDelay(backstageEvents)); - // Add running delay - let delay = 0; - for (const e of events) { - if (e.type === 'block') delay = 0; - else if (e.type === 'delay') delay = delay + e.duration; - else if (e.type === 'event' && delay > 0) { - e.timeStart += delay; - e.timeEnd += delay; - } - } - - // filter just events - let filtered = events.filter((e) => e.type === 'event'); - - setFilteredEvents(filtered); + console.log('hhh', getEventsWithDelay(backstageEvents)) }, [backstageEvents]); // Format messages diff --git a/client/src/features/viewers/presenter/PresenterView.jsx b/client/src/features/viewers/presenter/PresenterView.jsx index 59957f1f1..7e185b1e1 100644 --- a/client/src/features/viewers/presenter/PresenterView.jsx +++ b/client/src/features/viewers/presenter/PresenterView.jsx @@ -11,7 +11,7 @@ export default function PresenterView(props) { // Set window title useEffect(() => { - document.title = 'ontime - Speaker Screen'; + document.title = 'ontime - Presenter Screen'; }, []); const showOverlay = pres.text !== '' && pres.visible; diff --git a/client/src/features/viewers/production/lower/LowerClean.jsx b/client/src/features/viewers/production/lower/LowerClean.jsx index fb9fc8c40..b26dec608 100644 --- a/client/src/features/viewers/production/lower/LowerClean.jsx +++ b/client/src/features/viewers/production/lower/LowerClean.jsx @@ -1,6 +1,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { useEffect, useState } from 'react'; import style from './LowerClean.module.css'; +import NavLogo from "../../../../common/components/nav/NavLogo"; export default function LowerClean(props) { const { lower, title, options } = props; @@ -86,6 +87,9 @@ export default function LowerClean(props) { fontSize: `${sizeMultiplier}vh`, }} > + + + {showLower && ( + + + {showLower && ( { + document.title = 'ontime - Studio Clock'; + }, []); + + // Prepare event list + // Todo: useMemo() + useEffect(() => { + if (backstageEvents == null) return; + + const events = backstageEvents.filter((e) => e.type === 'event'); + + let e = trimEventlist(events, selectedId, MAX_TITLES); + e = formatEventList(e, selectedId, nextId); + setSchedule(e); + + }, [backstageEvents, selectedId, nextId]); + + return ( +
+ +
+
{time.clockNoSeconds}
+
+ {title.titleNext} +
+
0 ? style.nextCountdown: style.nextCountdown__overtime}> + {selectedId != null && formatDisplay(time.running)} +
+
+ {hoursIndicators.map(i => ( +
+ ) + )} + {minutesIndicators.map(i => ( +
+ ) + )} +
+
+
+
ON AIR
+
+
    + {schedule.map((s) => ( +
  • +
    {`${s.time} ${s.title}`} +
  • + )) + } +
+
+
+
+ ); +} diff --git a/client/src/features/viewers/studio/StudioClock.module.scss b/client/src/features/viewers/studio/StudioClock.module.scss new file mode 100644 index 000000000..ebf1f7e53 --- /dev/null +++ b/client/src/features/viewers/studio/StudioClock.module.scss @@ -0,0 +1,190 @@ +@font-face { + font-family: "digital-clock"; + src: local('digital-7'), url('./../../../assets/fonts/digital-7.mono.ttf') format('truetype') ; +} + +.container { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + height: 100vh; + padding: 1vw; + + background: #111; + + display: grid; + grid-template-columns: 1000px 1fr; + gap: 50px; + grid-template-areas: "clck schd"; + + /* =============== CLOCK STUFF ==================*/ + + $clock-size: 900px; + $size-hours: 20px; + $half-hours: 10px; + $size-min: 18px; + $half-min: 9px; + $red-active: #c53030; + $red-idle: darken($red-active, 30%); + $cyan-active: #0ff; + $cyan-idle: #0aa; + + .clockContainer { + display: grid; + place-content: center; + grid-area: clck; + width: $clock-size; + height: $clock-size; + text-align: center; + position: relative; + margin: auto; + font-family: digital-clock, monospace; + text-transform: uppercase; + + + .time { + margin-top: 175px; + color: $red-active; + font-size: 275px; + line-height: 0.8em; + text-shadow: rgb(180,0,0) 0 0 20px; + } + .nextTitle:after, + .nextCountdown:after, + .nextCountdown__overtime:after { + content: '\200b'; + } + .nextTitle { + color:$cyan-idle; + line-height: 100px; + } + .nextCountdown, + .nextCountdown__overtime { + font-size: 100px; + line-height: 1em; + } + .nextCountdown { + color: $cyan-active; + text-shadow: rgb(0,100,100) 0 0 20px; + } + .nextCountdown::before { + content: '-'; + } + .nextCountdown__overtime { + color: darken($red-active, 10%); + } + .indicators { + position: absolute; + width: 100%; + height: 100%; + + .min, + .min__active, + .hours, + .hours__active { + border-radius: 50%; + position: absolute; + background: $red-idle; + } + .min, + .min__active { + min-height: $size-min; + width: $size-min; + top: calc(50% - #{$half_min}); + left: calc(50% - #{$half_min}); + } + .hours, + .hours__active{ + min-height:$size-hours; + width: $size-hours; + top: calc(50% - #{$half_hours}); + left: calc(50% - #{$half_hours}); + } + .min__active, + .hours__active { + background: $red-active; + box-shadow: 0 0 10px 2px rgba(255,0,0,0.25); + } + } + } + + /* ============= SCHEDULE STUFF =================*/ + + .scheduleContainer { + grid-area: schd; + margin: 50px 0; + padding-right: 50px; + font-family: digital-clock, monospace; + text-transform: uppercase; + + + .onAir, + .onAir__idle{ + padding-bottom: 50px; + font-size: 190px; + line-height: 0.9em; + letter-spacing: 0.05em; + } + .onAir { + color: $red-active; + text-shadow: rgb(150,0,0) 0 0 20px; + } + .onAir__idle { + color: $red-idle; + } + .schedule { + + ul { + color: $cyan-idle; + line-height: 1em; + list-style: none; + font-size: 40px; + } + li { + padding-bottom: 0.5em; + display: flex; + align-items: center; + gap: 20px; + } + .now { + color: $cyan-active; + text-shadow: rgb(0,100,100) 0 0 20px; + } + .next { + color: $red-active; + text-shadow: rgb(100,0,0) 0 0 20px; + } + .decorator, + .decorator__active, + .decorator__next { + aspect-ratio: 1; + border-radius: 50%; + background: $red-idle; + min-height: $size-hours; + max-height: $size-hours; + width: $size-hours; + } + .decorator__active { + background: $cyan-active; + box-shadow: 0 0 10px 2px rgba(0,255,255,0.25); + } + .decorator__next{ + background: $red-active; + box-shadow: 0 0 10px 2px rgba(255,0,0,0.25); + } + } + } +} + +@media only screen and (max-width: 1600px) { + .container { + display: grid; + grid-template-areas: "clck"; + grid-template-columns: 100%; + place-content: center; + } + .scheduleContainer{ + display: none; + } +} diff --git a/client/yarn.lock b/client/yarn.lock index a15a4edc8..967a81dfc 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3073,6 +3073,14 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -3990,6 +3998,21 @@ check-types@^11.1.1: resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f" integrity sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== +"chokidar@>=3.0.0 <4.0.0": + version "3.5.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" + integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -6054,7 +6077,7 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@^2.1.2, fsevents@^2.1.3, fsevents@~2.3.1: +fsevents@^2.1.2, fsevents@^2.1.3, fsevents@~2.3.1, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -6137,7 +6160,7 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: +glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -6608,6 +6631,11 @@ immer@8.0.1: resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== +immutable@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23" + integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -10366,6 +10394,13 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + recursive-readdir@2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" @@ -10565,6 +10600,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -10811,6 +10851,14 @@ sass-loader@^10.0.5: schema-utils "^3.0.0" semver "^7.3.2" +sass@^1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.44.0.tgz#619aa0a2275c097f9af5e6b8fe8a95e3056430fb" + integrity sha512-0hLREbHFXGQqls/K8X+koeP+ogFRPF4ZqetVB19b7Cst9Er8cOR0rc6RU7MaI4W1JmUShd1BPgPoeqmmgMMYFw== + dependencies: + chokidar ">=3.0.0 <4.0.0" + immutable "^4.0.0" + sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -12131,6 +12179,13 @@ use-callback-ref@^1.2.3, use-callback-ref@^1.2.5: resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.5.tgz#6115ed242cfbaed5915499c0a9842ca2912f38a5" integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg== +use-fit-text@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/use-fit-text/-/use-fit-text-2.4.0.tgz#d3d1cd72f6d29cfb2233ec0e0b38c6f25d319f67" + integrity sha512-Iy4LMrXcdxWlyZ5phntMpJMgyXGB1p3tV73y2r0QrZ6f/thPh+/QU3ie6RCXmjF8tHMs20FKMPskXeDYIla/Ww== + dependencies: + resize-observer-polyfill "^1.5.1" + use-memo-one@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" diff --git a/server/package.json b/server/package.json index dca7b404e..3a1099646 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "0.4.1", + "version": "0.4.2", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/server/src/classes/EventTimer.js b/server/src/classes/EventTimer.js index 2224b2d72..c7fc10943 100644 --- a/server/src/classes/EventTimer.js +++ b/server/src/classes/EventTimer.js @@ -63,6 +63,7 @@ export class EventTimer extends Timer { nextPublicEventId = null; numEvents = null; _eventlist = null; + onAir = false; constructor(httpServer, oscClient, config) { // call super constructor @@ -199,7 +200,7 @@ export class EventTimer extends Timer { // send current timer broadcastTimer() { // through websockets - this.io.emit('timer', this.getObject()); + this.io.emit('timer', this.getTimes()); // through OSC, only if running if (this.state === 'start' || this.state === 'roll') { @@ -211,7 +212,7 @@ export class EventTimer extends Timer { // broadcast state broadcastState(update = true) { - this.io.emit('timer', this.getObject(update)); + this.io.emit('timer', this.getTimes(update)); this.io.emit('playstate', this.state); this.io.emit('selected', { id: this.selectedEventId, @@ -224,6 +225,7 @@ export class EventTimer extends Timer { this.io.emit('publicnext-id', this.nextPublicEventId); this.io.emit('titles', this.titles); this.io.emit('publictitles', this.titlesPublic); + this.io.emit('onAir', this.onAir); } // broadcast message @@ -359,7 +361,7 @@ export class EventTimer extends Timer { ); // send state - socket.emit('timer', this.getObject()); + socket.emit('timer', this.getTimes()); socket.emit('playstate', this.state); socket.emit('selected-id', this.selectedEventId); socket.emit('next-id', this.nextEventId); @@ -385,7 +387,7 @@ export class EventTimer extends Timer { /*******************************************/ // general playback state socket.on('get-state', () => { - socket.emit('timer', this.getObject()); + socket.emit('timer', this.getTimes()); socket.emit('playstate', this.state); socket.emit('selected-id', this.selectedEventId); socket.emit('next-id', this.nextEventId); @@ -400,7 +402,7 @@ export class EventTimer extends Timer { }); socket.on('get-timer', () => { - socket.emit('timer', this.getObject()); + socket.emit('timer', this.getTimes()); }); socket.on('increment-timer', (data) => { @@ -419,6 +421,15 @@ export class EventTimer extends Timer { socket.emit('playstate', this.state); }); + socket.on('set-onAir', (data) => { + this.onAir = data; + this.broadcastThis('onAir', this.onAir); + }); + + socket.on('get-onAir', () => { + socket.emit('onAir', this.onAir); + }); + /*******************************************/ // selection data socket.on('get-selected', () => { @@ -949,6 +960,14 @@ export class EventTimer extends Timer { `; } + /** + * @description Set onAir property of timer + * @param {boolean} onAir - whether flag is active + */ + set onAir(onAir) { + this.onAir = onAir; + } + start() { // if there is nothing selected, no nothing if (this.selectedEventId == null) return; diff --git a/server/src/classes/Timer.js b/server/src/classes/Timer.js index 5ed019a14..8cf6eea38 100644 --- a/server/src/classes/Timer.js +++ b/server/src/classes/Timer.js @@ -139,8 +139,8 @@ export class Timer { return this.duration - this.current; } - // getObject - getObject(update = true) { + // get time object + getTimes(update = true) { // update timer if (update) this.update(); diff --git a/server/src/controllers/OscController.js b/server/src/controllers/OscController.js index a9a59e1b4..5eff71e31 100644 --- a/server/src/controllers/OscController.js +++ b/server/src/controllers/OscController.js @@ -38,6 +38,12 @@ export const initiateOSC = (config) => { // get second part (command) switch (path.toLowerCase()) { + case 'onAir': + global.timer.onAir = true; + break; + case 'offAir': + global.timer.onAir = false; + break; case 'start': case 'play': console.log('calling play'); diff --git a/server/src/controllers/playbackController.js b/server/src/controllers/playbackController.js index 16b3d7f78..0e2477878 100644 --- a/server/src/controllers/playbackController.js +++ b/server/src/controllers/playbackController.js @@ -4,6 +4,22 @@ export const pbGet = async (req, res) => { res.send(global.timer.playState); }; +// Create controller for GET request to '/playback/onAir' +// Turns onAir flag to true +export const onAir = async (req, res) => { + console.log('Setting onAir to true'); + global.timer.onAir = true; + res.sendStatus(200); +}; + +// Create controller for GET request to '/playback/onAir' +// Turns onAir flag to true +export const offAir = async (req, res) => { + console.log('Setting onAir to false'); + global.timer.onAir = false; + res.sendStatus(200); +}; + // Create controller for GET request to '/playback/start' // Starts timer object export const pbStart = async (req, res) => { diff --git a/server/src/routes/playbackRouter.js b/server/src/routes/playbackRouter.js index 3b71b9c86..d31d1bc99 100644 --- a/server/src/routes/playbackRouter.js +++ b/server/src/routes/playbackRouter.js @@ -7,6 +7,12 @@ const playbackController = require('../controllers/playbackController'); // create route between controller and '/playback/' endpoint router.get('/', playbackController.pbGet); +// create route between controller and '/playback/onAir' endpoint +router.get('/onAir', playbackController.onAir); + +// create route between controller and '/playback/offAir' endpoint +router.get('/offAir', playbackController.offAir); + // create route between controller and '/playback/start' endpoint router.get('/start', playbackController.pbStart);