diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index f2d5fbb5f..4ce6edc2b 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -27,6 +27,7 @@ export const useMessageControl = () => { timerMessage: state.timerMessage, publicMessage: state.publicMessage, lowerMessage: state.lowerMessage, + externalMessage: state.externalMessage, onAir: state.onAir, }); @@ -40,6 +41,8 @@ export const setMessage = { publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload), lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload), lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload), + externalText: (payload: string) => socketSendJson('set-external-message-text', payload), + externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload), onAir: (payload: boolean) => socketSendJson('set-onAir', payload), timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload), timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload), diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index 3f503308d..98a04c134 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types'; import { useStore } from 'zustand'; import { createStore } from 'zustand/vanilla'; -export const runtimeStorePlaceholder = { +export const runtimeStorePlaceholder: RuntimeStore = { timer: { clock: 0, current: null, @@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = { text: '', visible: false, }, + externalMessage: { + text: '', + visible: false, + }, onAir: false, loaded: { numEvents: 0, diff --git a/apps/client/src/features/control/message/InputRow.tsx b/apps/client/src/features/control/message/InputRow.tsx index 9683dec02..561c19740 100644 --- a/apps/client/src/features/control/message/InputRow.tsx +++ b/apps/client/src/features/control/message/InputRow.tsx @@ -1,4 +1,4 @@ -import { Input } from '@chakra-ui/react'; +import { IconButton, Input } from '@chakra-ui/react'; import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; @@ -13,13 +13,14 @@ interface InputRowProps { placeholder: string; text: string; visible?: boolean; + readonly?: boolean; actionHandler: (action: string, payload: object) => void; changeHandler: (newValue: string) => void; className?: string; } export default function InputRow(props: InputRowProps) { - const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props; + const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props; const handleInputChange = (newValue: string) => { changeHandler(newValue); @@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) { handleInputChange(event.target.value)} placeholder={placeholder} /> - actionHandler('update', { field: 'isPublic', value: !visible })} - tooltip={visible ? 'Make invisible' : 'Make visible'} - aria-label={`Toggle ${label}`} - openDelay={tooltipDelayMid} - icon={visible ? : } - variant={visible ? 'ontime-filled' : 'ontime-subtle'} - size='sm' - /> + {readonly ? ( + : } + aria-label={`Toggle ${label}`} + variant={visible ? 'ontime-filled' : 'ontime-subtle'} + /> + ) : ( + actionHandler('update', { field: 'isPublic', value: !visible })} + tooltip={visible ? 'Make invisible' : 'Make visible'} + aria-label={`Toggle ${label}`} + openDelay={tooltipDelayMid} + icon={visible ? : } + variant={visible ? 'ontime-filled' : 'ontime-subtle'} + size='sm' + /> + )} ); diff --git a/apps/client/src/features/control/message/MessageControl.module.scss b/apps/client/src/features/control/message/MessageControl.module.scss index ab966b116..5f3d0e46f 100644 --- a/apps/client/src/features/control/message/MessageControl.module.scss +++ b/apps/client/src/features/control/message/MessageControl.module.scss @@ -27,7 +27,3 @@ color: $action-text-color; } } - -.padTop { - margin-top: $section-spacing; -} diff --git a/apps/client/src/features/control/message/MessageControl.tsx b/apps/client/src/features/control/message/MessageControl.tsx index 73138367b..8feb66cc9 100644 --- a/apps/client/src/features/control/message/MessageControl.tsx +++ b/apps/client/src/features/control/message/MessageControl.tsx @@ -34,34 +34,48 @@ export default function MessageControl() { actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)} /> setMessage.presenterText(newValue)} actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)} />
- -
-
+ undefined} + actionHandler={() => undefined} + /> +
-
+
{showEndMessage ? (
{viewSettings.endMessage}
) : ( @@ -124,6 +131,12 @@ export default function Timer(props: TimerProps) { {display}
)} +
+ {external.text} +
{ const initialPayload = getInitialPayload(); eventStore.init(initialPayload); + // eventStore set is a dependency of the services that publish to it + messageService.init(eventStore.set.bind(eventStore)); + expressServer.listen(serverPort, '0.0.0.0'); return { message: returnMessage, serverPort }; diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index a8f5bd95d..a37579a37 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -109,6 +109,20 @@ export function dispatchFromAdapter( break; } + case 'set-external-message-text': { + if (typeof payload !== 'string') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setExternalText(payload); + return; + } + case 'set-external-message-visible': { + if (typeof payload === 'undefined') { + throw new Error(`Unable to parse payload: ${payload}`); + } + messageService.setExternalVisibility(Boolean(payload)); + break; + } case 'start': { PlaybackService.start(); break; diff --git a/apps/server/src/external/styles/override.css b/apps/server/src/external/styles/override.css index 63c304c7d..cc5a7cf21 100644 --- a/apps/server/src/external/styles/override.css +++ b/apps/server/src/external/styles/override.css @@ -12,6 +12,8 @@ --timer-progress-bg-override: #fff; --timer-progress-override: #202020; + --external-color-override: #161616; + --cuesheet-running-bg-override: #D20300; --operator-running-bg-override: #D20300; diff --git a/apps/server/src/services/message-service/MessageService.ts b/apps/server/src/services/message-service/MessageService.ts index c1878cee8..5ab3bf048 100644 --- a/apps/server/src/services/message-service/MessageService.ts +++ b/apps/server/src/services/message-service/MessageService.ts @@ -1,7 +1,9 @@ import { Message } from 'ontime-types'; -import { eventStore } from '../../stores/EventStore.js'; import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js'; +import { throttle } from '../../utils/throttle.js'; + +import type { PublishFn } from '../../stores/EventStore.js'; let instance; @@ -9,8 +11,12 @@ class MessageService { timerMessage: TimerMessage; publicMessage: Message; lowerMessage: Message; + externalMessage: Message; onAir: boolean; + private throttledSet: PublishFn; + private publish: PublishFn | null; + constructor() { if (instance) { throw new Error('There can be only one'); @@ -36,7 +42,40 @@ class MessageService { visible: false, }; + this.externalMessage = { + text: '', + visible: false, + }; + this.onAir = false; + this.throttledSet = () => { + throw new Error('Published called before initialisation'); + }; + } + + init(publish: PublishFn) { + this.publish = publish; + this.throttledSet = throttle((key, value) => this.publish(key, value), 100); + } + + /** + * @description sets message on stage timer screen + */ + setExternalText(payload: string) { + if (this.externalMessage.text !== payload) { + this.externalMessage.text = payload; + this.throttledSet('externalMessage', this.externalMessage); + } + return this.getAll(); + } + + /** + * @description sets message visibility on stage timer screen + */ + setExternalVisibility(status: boolean) { + this.externalMessage.visible = status; + this.throttledSet('externalMessage', this.externalMessage); + return this.getAll(); } /** @@ -44,7 +83,7 @@ class MessageService { */ setTimerText(payload: string) { this.timerMessage.text = payload; - eventStore.set('timerMessage', this.timerMessage); + this.throttledSet('timerMessage', this.timerMessage); return this.getAll(); } @@ -53,7 +92,7 @@ class MessageService { */ setTimerVisibility(status: boolean) { this.timerMessage.visible = status; - eventStore.set('timerMessage', this.timerMessage); + this.throttledSet('timerMessage', this.timerMessage); return this.getAll(); } @@ -62,7 +101,7 @@ class MessageService { */ setPublicText(payload: string) { this.publicMessage.text = payload; - eventStore.set('publicMessage', this.publicMessage); + this.throttledSet('publicMessage', this.publicMessage); return this.getAll(); } @@ -71,7 +110,7 @@ class MessageService { */ setPublicVisibility(status: boolean) { this.publicMessage.visible = status; - eventStore.set('publicMessage', this.publicMessage); + this.throttledSet('publicMessage', this.publicMessage); return this.getAll(); } @@ -80,7 +119,7 @@ class MessageService { */ setLowerText(payload: string) { this.lowerMessage.text = payload; - eventStore.set('lowerMessage', this.lowerMessage); + this.throttledSet('lowerMessage', this.lowerMessage); return this.getAll(); } @@ -89,7 +128,7 @@ class MessageService { */ setLowerVisibility(status: boolean) { this.lowerMessage.visible = status; - eventStore.set('lowerMessage', this.lowerMessage); + this.throttledSet('lowerMessage', this.lowerMessage); return this.getAll(); } @@ -102,7 +141,7 @@ class MessageService { } else { this.onAir = status; } - eventStore.set('onAir', this.onAir); + this.throttledSet('onAir', this.onAir); return this.getAll(); } @@ -116,7 +155,7 @@ class MessageService { } else { this.timerMessage.timerBlink = status; } - eventStore.set('timerMessage', this.timerMessage); + this.throttledSet('timerMessage', this.timerMessage); return this.getAll(); } @@ -130,7 +169,7 @@ class MessageService { } else { this.timerMessage.timerBlackout = status; } - eventStore.set('timerMessage', this.timerMessage); + this.throttledSet('timerMessage', this.timerMessage); return this.getAll(); } diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts index 19e59ded9..a6410b0bf 100644 --- a/apps/server/src/stores/EventStore.ts +++ b/apps/server/src/stores/EventStore.ts @@ -4,6 +4,8 @@ import { eventTimer } from '../services/TimerService.js'; import { messageService } from '../services/message-service/MessageService.js'; import { eventLoader } from '../classes/event-loader/EventLoader.js'; +export type PublishFn = (key: T, value: RuntimeStore[T]) => void; + let store: Partial = {}; /** @@ -68,6 +70,7 @@ export const getInitialPayload = () => ({ timerMessage: messageService.timerMessage, publicMessage: messageService.publicMessage, lowerMessage: messageService.lowerMessage, + externalMessage: messageService.externalMessage, onAir: messageService.onAir, loaded: eventLoader.loaded, eventNow: eventLoader.eventNow, diff --git a/apps/server/src/utils/throttle.ts b/apps/server/src/utils/throttle.ts new file mode 100644 index 000000000..275d9772b --- /dev/null +++ b/apps/server/src/utils/throttle.ts @@ -0,0 +1,32 @@ +/** + * Creates a throttled version of the passed function + * This function uses a leading algorithm + * which means that the function will be executed immediately on first call + * @param {Function} cb - function to throttle + * @param {number} delay - time (in ms) to throttle + * @returns {Function} + */ +export function throttle(cb: (...args: T) => U, delay: number) { + let shouldWait = false; + let waitingArgs; + const timeoutFunc = () => { + if (waitingArgs == null) { + shouldWait = false; + } else { + cb(...waitingArgs); + waitingArgs = null; + setTimeout(timeoutFunc, delay); + } + }; + + return (...args: T) => { + if (shouldWait) { + waitingArgs = args; + return; + } + + cb(...args); + shouldWait = true; + setTimeout(timeoutFunc, delay); + }; +} diff --git a/e2e/tests/features/201-message-control.spec.ts b/e2e/tests/features/201-message-control.spec.ts index b36d3c85a..dbe55e417 100644 --- a/e2e/tests/features/201-message-control.spec.ts +++ b/e2e/tests/features/201-message-control.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -test('test', async ({ context }) => { +test('message control sends messages to screens', async ({ context }) => { const editorPage = await context.newPage(); const featurePage = await context.newPage(); @@ -25,9 +25,9 @@ test('test', async ({ context }) => { await featurePage.getByText('testing lower').click({ timeout: 5000 }); // stage timer message - await editorPage.getByPlaceholder('Shown in stage timer').click(); - await editorPage.getByPlaceholder('Shown in stage timer').fill('testing stage'); - await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 }); + await editorPage.getByPlaceholder('Timer').click(); + await editorPage.getByPlaceholder('Timer').fill('testing stage'); + await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 }); await featurePage.goto('http://localhost:4001/timer'); await featurePage.waitForLoadState('load', { timeout: 5000 }); diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index d9f68cbba..37d1c2d03 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -13,6 +13,7 @@ export type RuntimeStore = { timerMessage: TimerMessage; publicMessage: Message; lowerMessage: Message; + externalMessage: Message; onAir: boolean; // event loader