diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index eab5f0f52..43fb648f0 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -1,4 +1,4 @@ -import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types'; +import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { useRuntimeStore } from '../stores/runtime'; import { socketSendJson } from '../utils/socket'; @@ -28,11 +28,43 @@ export const useOperator = () => { return useRuntimeStore(featureSelector); }; -export const useMessageControl = () => { +export const useTimerViewControl = () => { const featureSelector = (state: RuntimeStore) => ({ - timer: state.message.timer, - external: state.message.external, - onAir: state.onAir, + blackout: state.message.timer.blackout, + blink: state.message.timer.blink, + secondarySource: state.message.timer.secondarySource, + }); + + return useRuntimeStore(featureSelector); +}; + +export const useTimerMessageInput = () => { + const featureSelector = (state: RuntimeStore) => ({ + text: state.message.timer.text, + visible: state.message.timer.visible, + }); + + return useRuntimeStore(featureSelector); +}; + +export const useExternalMessageInput = () => { + const featureSelector = (state: RuntimeStore) => ({ + text: state.message.external, + visible: state.message.timer.secondarySource === 'external', + }); + + return useRuntimeStore(featureSelector); +}; + +export const useMessagePreview = () => { + const featureSelector = (state: RuntimeStore) => ({ + blink: state.message.timer.blink, + blackout: state.message.timer.blackout, + phase: state.timer.phase, + showAuxTimer: state.message.timer.secondarySource === 'aux', + showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external), + showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), + timerType: state.eventNow?.timerType ?? null, }); return useRuntimeStore(featureSelector); @@ -41,8 +73,11 @@ export const useMessageControl = () => { export const setMessage = { timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }), timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }), + externalText: (payload: string) => socketSendJson('message', { external: payload }), timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }), timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }), + timerSecondary: (payload: TimerMessage['secondarySource']) => + socketSendJson('message', { timer: { secondarySource: payload } }), }; export const usePlaybackControl = () => { diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index 8c20128a7..849a646ac 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -23,11 +23,9 @@ export const runtimeStorePlaceholder: RuntimeStore = { visible: false, blink: false, blackout: false, + secondarySource: null, }, - external: { - text: '', - visible: false, - }, + external: '', }, runtime: { selectedEventIndex: null, diff --git a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx index f226fe7be..3dd12f14d 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx @@ -147,7 +147,7 @@ export default function ViewSettingsForm() { variant='ontime-filled' maxLength={150} width='275px' - placeholder='Message shown when timer reaches end' + placeholder='Shown when timer reaches end' {...register('endMessage')} /> diff --git a/apps/client/src/features/control/message/InputRow.tsx b/apps/client/src/features/control/message/InputRow.tsx index 58c58e609..e200d1289 100644 --- a/apps/client/src/features/control/message/InputRow.tsx +++ b/apps/client/src/features/control/message/InputRow.tsx @@ -1,10 +1,9 @@ import { useEffect, useRef } from 'react'; -import { IconButton, Input } from '@chakra-ui/react'; +import { Input } from '@chakra-ui/react'; import { IoEye } from '@react-icons/all-files/io5/IoEye'; import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; -import { cx } from '../../../common/utils/styleUtils'; import { tooltipDelayMid } from '../../../ontimeConfig'; import style from './InputRow.module.scss'; @@ -13,15 +12,13 @@ interface InputRowProps { label: string; placeholder: string; text: string; - visible?: boolean; - readonly?: boolean; - actionHandler: (action: string, payload: object) => void; + visible: boolean; + actionHandler: () => void; changeHandler: (newValue: string) => void; - className?: string; } export default function InputRow(props: InputRowProps) { - const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props; + const { label, placeholder, text, visible, actionHandler, changeHandler } = props; const inputRef = useRef(null); const cursorPositionRef = useRef(0); @@ -39,41 +36,27 @@ export default function InputRow(props: InputRowProps) { changeHandler(event.target.value); }; - const classes = cx([style.inputRow, className]); - return ( -
+
- {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' - /> - )} + : } + 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 3419afec2..28d02d9da 100644 --- a/apps/client/src/features/control/message/MessageControl.module.scss +++ b/apps/client/src/features/control/message/MessageControl.module.scss @@ -1,23 +1,3 @@ -.messageContainer { - display: flex; - flex-direction: column; - gap: $section-spacing; -} - -.buttonSection { - display: grid; - grid-template-columns: 1fr 1fr; - gap: $element-spacing; - margin-top: -0.5rem; -} - -.singleAction { - display: flex; - flex-direction: column; - gap: $element-spacing; - margin-top: $element-inner-spacing; -} - .label { font-size: $inner-section-text-size; color: $label-gray; @@ -26,3 +6,73 @@ color: $action-text-color; } } + +.previewContainer { + display: grid; + gap: $element-spacing; + grid-template-columns: 2fr 1fr; +} + +.preview { + background-color: $ui-black; + display: grid; + place-content: center; + text-align: center; + position: relative; +} + +.options { + display: flex; + flex-direction: column; + gap: $element-spacing; +} + +.eventStatus { + position: absolute; + right: 0; + margin: 0.5rem 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.mainContent { + font-size: 1rem; + font-weight: 600; + color: var(--override-colour, $ui-white); + + &[data-phase='pending'] { + color: $ontime-roll; + } + &[data-phase='overtime'] { + color: $playback-negative; + } + &[data-phase='none'] { + opacity: $opacity-disabled; + } +} + +.secondaryContent { + border-top: 1px solid $white-7; +} + +.blackout { + display: none; +} + +.timerIndicators { + display: flex; + flex-direction: column; +} + +.statusIcon { + color: $gray-1000; + + &[data-active='true'] { + color: $active-indicator; + } +} + +.divider { + border-top: 1px solid $gray-1000; +} diff --git a/apps/client/src/features/control/message/MessageControl.tsx b/apps/client/src/features/control/message/MessageControl.tsx index ae08096b7..2c1118a5e 100644 --- a/apps/client/src/features/control/message/MessageControl.tsx +++ b/apps/client/src/features/control/message/MessageControl.tsx @@ -1,64 +1,52 @@ -import { Button } from '@chakra-ui/react'; -import { IoEye } from '@react-icons/all-files/io5/IoEye'; -import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline'; -import { IoSunny } from '@react-icons/all-files/io5/IoSunny'; -import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline'; - -import { setMessage, useMessageControl } from '../../../common/hooks/useSocket'; -import { enDash } from '../../../common/utils/styleUtils'; +import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket'; import InputRow from './InputRow'; - -import style from './MessageControl.module.scss'; - -const noop = () => undefined; +import TimerControlsPreview from './TimerViewControl'; export default function MessageControl() { - const message = useMessageControl(); - const blink = message.timer.blink; - const blackout = message.timer.blackout; - return ( -
- setMessage.timerText(newValue)} - actionHandler={() => setMessage.timerVisible(!message.timer.visible)} - /> -
- - -
- -
+ <> + + + + + ); +} + +function TimerMessageInput() { + const { text, visible } = useTimerMessageInput(); + + return ( + setMessage.timerText(newValue)} + actionHandler={() => setMessage.timerVisible(!visible)} + /> + ); +} + +function ExternalInput() { + const { text, visible } = useExternalMessageInput(); + + const toggleExternal = () => { + if (visible) { + setMessage.timerSecondary(null); + } else { + setMessage.timerSecondary('external'); + } + }; + + return ( + setMessage.externalText(newValue)} + actionHandler={toggleExternal} + /> ); } diff --git a/apps/client/src/features/control/message/MessageControlExport.jsx b/apps/client/src/features/control/message/MessageControlExport.jsx index 5c6ba1962..54cd1789b 100644 --- a/apps/client/src/features/control/message/MessageControlExport.jsx +++ b/apps/client/src/features/control/message/MessageControlExport.jsx @@ -3,16 +3,19 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import { handleLinks } from '../../../common/utils/linkUtils'; +import { cx } from '../../../common/utils/styleUtils'; import MessageControl from './MessageControl'; import style from '../../editors/Editor.module.scss'; const MessageControlExport = () => { + const classes = cx([style.content, style.contentColumnLayout]); + return (
handleLinks(event, 'messagecontrol')} /> -
+
diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx new file mode 100644 index 000000000..a936ee9da --- /dev/null +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -0,0 +1,76 @@ +import { Tooltip } from '@chakra-ui/react'; +import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; +import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; +import { IoFlag } from '@react-icons/all-files/io5/IoFlag'; +import { IoTime } from '@react-icons/all-files/io5/IoTime'; +import { TimerPhase, TimerType } from 'ontime-types'; + +import { useMessagePreview } from '../../../common/hooks/useSocket'; +import useViewSettings from '../../../common/hooks-query/useViewSettings'; +import { cx } from '../../../common/utils/styleUtils'; +import { tooltipDelayMid } from '../../../ontimeConfig'; + +import style from './MessageControl.module.scss'; + +export default function TimerPreview() { + const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } = + useMessagePreview(); + const { data } = useViewSettings(); + + const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]); + + const main = (() => { + if (showTimerMessage) return 'Message'; + if (phase === TimerPhase.Pending) return 'Standby to start'; + if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message'; + return 'Timer'; + })(); + + const secondary = (() => { + // message is a fullscreen overlay + if (showTimerMessage) return null; + + // we need to check aux first since it takes priority + if (showAuxTimer) return 'Aux Timer'; + if (showExternalMessage) return 'External message'; + return null; + })(); + + const overrideColour = (() => { + // override fallback colours from starter project + if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33'; + if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333'; + return data.normalColor ?? '#FFFC'; + })(); + + const showColourOverride = main == 'Timer'; + + return ( +
+
+
+ {main} +
+ {secondary !== null &&
{secondary}
} +
+
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/client/src/features/control/message/TimerViewControl.tsx b/apps/client/src/features/control/message/TimerViewControl.tsx new file mode 100644 index 000000000..8bef7ecde --- /dev/null +++ b/apps/client/src/features/control/message/TimerViewControl.tsx @@ -0,0 +1,62 @@ +import { Button } from '@chakra-ui/react'; + +import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket'; + +import TimerPreview from './TimerPreview'; + +import style from './MessageControl.module.scss'; + +export default function TimerControlsPreview() { + const { blackout, blink, secondarySource } = useTimerViewControl(); + + const toggleSecondary = (newValue: 'aux' | 'external' | null) => { + if (secondarySource === newValue) { + setMessage.timerSecondary(null); + } else { + setMessage.timerSecondary(newValue); + } + }; + + return ( +
+ + +
+ + + +
+ + + +
+
+ ); +} diff --git a/apps/client/src/features/control/playback/aux-timer/AuxTimer.tsx b/apps/client/src/features/control/playback/aux-timer/AuxTimer.tsx index 084d6d322..ae2b8ecde 100644 --- a/apps/client/src/features/control/playback/aux-timer/AuxTimer.tsx +++ b/apps/client/src/features/control/playback/aux-timer/AuxTimer.tsx @@ -68,7 +68,7 @@ function AuxTimerInput() { const handleTimeUpdate = (_field: string, value: string) => { const newTime = parseUserTime(value); - setDuration(newTime / 1000); //frontend api is seconds based; + setDuration(newTime / 1000); // frontend api is seconds based }; return ( diff --git a/apps/client/src/features/editors/Editor.module.scss b/apps/client/src/features/editors/Editor.module.scss index 1907ea485..bba27a6d2 100644 --- a/apps/client/src/features/editors/Editor.module.scss +++ b/apps/client/src/features/editors/Editor.module.scss @@ -40,7 +40,7 @@ $panel-gap: 0.5rem; .playback, .messages { position: relative; - border-radius: 8px; + border-radius: var(--editor--panel__br); background-color: $bg-container-l2; padding: 1rem; } @@ -62,3 +62,10 @@ $panel-gap: 0.5rem; .content { padding-top: 1.5rem; } + +.contentColumnLayout { + display: flex; + flex-direction: column; + gap: $section-spacing; + color: $ui-white; +} diff --git a/apps/client/src/features/editors/EditorMixin.scss b/apps/client/src/features/editors/EditorMixin.scss index 857428659..8b47104aa 100644 --- a/apps/client/src/features/editors/EditorMixin.scss +++ b/apps/client/src/features/editors/EditorMixin.scss @@ -1,10 +1,18 @@ @use '../../theme/ontimeColours' as *; @use '../../theme/ontimeStyles' as *; -@mixin absolute-top-right($distance) { +// declare editor specific styling constants +:root { + --editor--panel__br: 8px; +} + +@mixin corner() { + display: none; + transform: rotate(45deg); + position: absolute; - top: $distance; - right: $distance; + top: 0.5rem; + right: 0.5rem; cursor: pointer; color: $ui-white; transition-property: color; @@ -15,17 +23,11 @@ } } -@mixin corner() { - display: none; - @include absolute-top-right(0.5rem); - transform: rotate(45deg); -} - @mixin panel() { display: flex; position: relative; - border-radius: 8px; + border-radius: var(--editor--panel__br); height: 100%; background-color: $bg-container-l2; padding: 1rem; diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index 827fe3052..4adab46de 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -21,10 +21,6 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar'; import style from './EventBlock.module.scss'; -const tooltipProps = { - openDelay: tooltipDelayMid, -}; - interface EventBlockInnerProps { timeStart: number; timeEnd: number; @@ -98,11 +94,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
- {isNext && ( - - UP NEXT - - )} + {isNext && UP NEXT}
{ {loaded && }
- + - + - + diff --git a/apps/client/src/features/viewers/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx index f44a41103..277f94218 100644 --- a/apps/client/src/features/viewers/ViewWrapper.tsx +++ b/apps/client/src/features/viewers/ViewWrapper.tsx @@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react'; import { ViewExtendedTimer } from 'common/models/TimeManager.type'; import { CustomFields, - Message, + MessageState, OntimeEvent, ProjectData, Runtime, Settings, + SimpleTimerState, SupportedEvent, - TimerMessage, ViewSettings, } from 'ontime-types'; import { useStore } from 'zustand'; @@ -23,17 +23,17 @@ import { runtimeStore } from '../../common/stores/runtime'; import { useViewOptionsStore } from '../../common/stores/viewOptions'; type WithDataProps = { + auxTimer: SimpleTimerState; backstageEvents: OntimeEvent[]; customFields: CustomFields; eventNext: OntimeEvent | null; eventNow: OntimeEvent | null; events: OntimeEvent[]; - external: Message; general: ProjectData; isMirrored: boolean; + message: MessageState; nextId: string | null; onAir: boolean; - pres: TimerMessage; publicEventNext: OntimeEvent | null; publicEventNow: OntimeEvent | null; publicSelectedId: string | null; @@ -68,7 +68,7 @@ const withData =

(Component: ComponentType

) => { }, [rundownData]); // websocket data - const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } = + const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } = useStore(runtimeStore); const publicSelectedId = publicEventNow?.id ?? null; const selectedId = eventNow?.id ?? null; @@ -96,17 +96,17 @@ const withData =

(Component: ComponentType

) => { { + if (message.timer.secondarySource === 'aux') { + return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), { + removeSeconds: userOptions.hideTimerSeconds, + removeLeadingZero: userOptions.removeLeadingZeros, + }); + } + if (message.timer.secondarySource === 'external' && message.external) { + return message.external; + } + return; + })(); let timerColor = viewSettings.normalColor; if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor; @@ -146,13 +156,14 @@ export default function Timer(props: TimerProps) { const stageTimerCharacters = display.replace('/:/g', '').length; const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`; + let timerFontSize = 89 / (stageTimerCharacters - 1); // we need to shrink the timer if the external is going to be there - if (showExternal) { + if (secondaryContent) { timerFontSize *= 0.8; } const externalFontSize = timerFontSize * 0.4; - const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`; + const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`; const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`; const defaultFormat = getDefaultFormat(settings?.timeFormat); @@ -161,11 +172,11 @@ export default function Timer(props: TimerProps) { return (

-
+
{!userOptions.hideMessage && (
- - {pres.text} + + {message.timer.text}
)} @@ -192,10 +203,10 @@ export default function Timer(props: TimerProps) {
)}
- {external.text} + {secondaryContent}
diff --git a/apps/server/src/api-integration/__tests__/integration.legacy.test.ts b/apps/server/src/api-integration/__tests__/integration.legacy.test.ts new file mode 100644 index 000000000..6dd81fa28 --- /dev/null +++ b/apps/server/src/api-integration/__tests__/integration.legacy.test.ts @@ -0,0 +1,36 @@ +import { handleLegacyMessageConversion } from '../integration.legacy.js'; + +describe('handleLegacyConversion', () => { + it('should return the payload as is if it is not a legacy message', () => { + expect(handleLegacyMessageConversion({})).toEqual({}); + const newPayload = { + timer: { + text: 'text', + visible: true, + blink: true, + blackout: true, + }, + external: 'text', + }; + expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload); + }); + + it('should convert a legacy payload with external message', () => { + expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({ + external: 'text', + timer: { + secondarySource: 'external', + }, + }); + + expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({ + timer: { + secondarySource: 'external', + }, + }); + + expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({ + external: 'text', + }); + }); +}); diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index 9039bc299..d88c2fe6f 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -1,9 +1,11 @@ -import { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types'; +import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils'; +import { DeepPartial } from 'ts-essentials'; + import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js'; -import { messageService } from '../services/message-service/MessageService.js'; +import * as messageService from '../services/message-service/MessageService.js'; import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js'; import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { eventStore } from '../stores/EventStore.js'; @@ -14,6 +16,8 @@ import { socket } from '../adapters/WebsocketAdapter.js'; import { throttle } from '../utils/throttle.js'; import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js'; +import { handleLegacyMessageConversion } from './integration.legacy.js'; + const throttledUpdateEvent = throttle(updateEvent, 20); export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') { @@ -78,9 +82,12 @@ const actionHandlers: Record = { message: (payload) => { assert.isObject(payload); + // TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024 + const migratedPayload = handleLegacyMessageConversion(payload); + const patch: DeepPartial = { - timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined, - external: 'external' in payload ? validateMessage(payload.external) : undefined, + timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined, + external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined, }; const newMessage = messageService.patch(patch); diff --git a/apps/server/src/api-integration/integration.legacy.ts b/apps/server/src/api-integration/integration.legacy.ts new file mode 100644 index 000000000..d24a58080 --- /dev/null +++ b/apps/server/src/api-integration/integration.legacy.ts @@ -0,0 +1,67 @@ +import { MessageState } from 'ontime-types'; +import { DeepPartial } from 'ts-essentials'; + +export type LegacyMessageState = DeepPartial<{ + timer: { + text: string; + visible: boolean; + blink: boolean; + blackout: boolean; + }; + external: { + text: string; + visible: boolean; + }; +}>; + +function isLegacyMessageState(value: object): value is LegacyMessageState { + // @ts-expect-error -- good enough here + return value?.external?.text !== undefined || value?.external?.visible !== undefined; +} + +/** + * This function is used to maintain support for legacy data in the /message endpoint + * The previous message endpoint expected a patch of the message state + * @example { + * timer: { blink: boolean, blackout: boolean, text: string, visible: boolean }, + * external: { visible: boolean, text: string } + * } + * + * This change is introduced in version 3.6.0 + */ +export function handleLegacyMessageConversion(payload: object): object | Partial { + // if it is not a legacy message, we pass it as is + if (!isLegacyMessageState(payload)) { + return payload; + } + + /** + * The current migration only needs to handle the cases + * for the deprecated external message controls + */ + + // Migrate external message + // 2.1 the user gives us the text and a visible flag + if (payload?.external?.text !== undefined && payload.external.visible !== undefined) { + return { + timer: { secondarySource: payload.external.visible ? 'external' : null }, + external: payload.external.text, + } as Partial; + } + // 2.2 the user gives us the text + else if (payload?.external?.text !== undefined) { + return { + external: payload.external.text, + } as Partial; + } + // 2.3 the user gives us the visible flag + else if (payload?.external?.visible !== undefined) { + return { + timer: { secondarySource: payload.external.visible ? 'external' : null }, + } as Partial; + } + + // there should be no case for us to reach this since + // the type guard would have ensured one of the above states + return payload; +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6670a43a8..cf988db81 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -38,7 +38,7 @@ import { populateStyles } from './setup/loadStyles.js'; import { eventStore } from './stores/EventStore.js'; import { runtimeService } from './services/runtime-service/RuntimeService.js'; import { restoreService } from './services/RestoreService.js'; -import { messageService } from './services/message-service/MessageService.js'; +import * as messageService from './services/message-service/MessageService.js'; import { populateDemo } from './setup/loadDemo.js'; import { getState } from './stores/runtimeState.js'; import { initRundown } from './services/rundown-service/RundownService.js'; @@ -198,7 +198,7 @@ export const startServer = async ( // initialise logging service, escalateErrorFn is only exists in electron logger.init(escalateErrorFn); - // initialise rundown service + // initialise rundown service const persistedRundown = getDataProvider().getRundown(); const persistedCustomFields = getDataProvider().getCustomFields(); initRundown(persistedRundown, persistedCustomFields); @@ -210,7 +210,7 @@ export const startServer = async ( runtimeService.init(maybeRestorePoint); // eventStore set is a dependency of the services that publish to it - messageService.init(eventStore.set.bind(eventStore)); + messageService.init(eventStore.set); expressServer.listen(serverPort, '0.0.0.0', () => { const nif = getNetworkInterfaces(); diff --git a/apps/server/src/services/message-service/MessageService.ts b/apps/server/src/services/message-service/MessageService.ts index 0721a4fcc..16261457a 100644 --- a/apps/server/src/services/message-service/MessageService.ts +++ b/apps/server/src/services/message-service/MessageService.ts @@ -1,68 +1,63 @@ -import { DeepPartial, Message, TimerMessage, MessageState } from 'ontime-types'; +import { TimerMessage, MessageState } from 'ontime-types'; +import { DeepPartial } from 'ts-essentials'; import { throttle } from '../../utils/throttle.js'; - import type { PublishFn } from '../../stores/EventStore.js'; -let instance: MessageService | null = null; +const defaultTimer: TimerMessage = { + text: '', + visible: false, + blink: false, + blackout: false, + secondarySource: null, +}; -class MessageService { - timer: TimerMessage; - external: Message; +let timer = { ...defaultTimer }; +let external = ''; - private throttledSet: PublishFn; - private publish: PublishFn | null; +let throttledSet: PublishFn | null = null; - constructor() { - if (instance) { - throw new Error('There can be only one'); - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton - instance = this; - - this.throttledSet = () => { - throw new Error('Published called before initialisation'); - }; - - this.clear(); - } - - clear() { - this.timer = { - text: '', - visible: false, - blink: false, - blackout: false, - }; - - this.external = { - text: '', - visible: false, - }; - } - - init(publish: PublishFn) { - this.publish = publish; - this.throttledSet = throttle((key, value) => this.publish?.(key, value), 100); - } - - getState(): MessageState { - return { - timer: this.timer, - external: this.external, - }; - } - - patch(message: DeepPartial) { - if (message.timer) this.timer = { ...this.timer, ...message.timer }; - if (message.external) this.external = { ...this.external, ...message.external }; - - const newState = this.getState(); - - this.throttledSet('message', newState); - return newState; - } +/** + * Initialises the message service with a publish function + * @param publishFn + */ +export function init(publishFn: PublishFn) { + throttledSet = throttle(publishFn, 100); } -export const messageService = new MessageService(); +/** + * Exposes function to reset the internal state + */ +export function clear() { + timer = { ...defaultTimer }; + external = ''; +} + +/** + * Exposes the internal state of the message service + */ +export function getState(): MessageState { + return { + external, + timer, + }; +} + +/** + * Utility function allows patching internal object + */ +export function patch(patch: DeepPartial): MessageState { + // we cannot call patch before init + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (throttledSet === null) { + throw new Error('MessageService.patch() called before init()'); + } + } + + if ('timer' in patch) timer = { ...timer, ...patch.timer }; + if ('external' in patch && patch.external !== undefined) external = patch.external; + const newState = getState(); + throttledSet?.('message', newState); + return newState; +} diff --git a/apps/server/src/services/message-service/__tests__/MessageService.test.ts b/apps/server/src/services/message-service/__tests__/MessageService.test.ts index 23773cab0..1c780627d 100644 --- a/apps/server/src/services/message-service/__tests__/MessageService.test.ts +++ b/apps/server/src/services/message-service/__tests__/MessageService.test.ts @@ -1,4 +1,4 @@ -import { messageService } from '../MessageService.js'; +import * as messageService from '../MessageService.js'; describe('MessageService', () => { const publishFunction = () => {}; @@ -14,17 +14,14 @@ describe('MessageService', () => { it('should patch the message state', () => { const message = { timer: { text: 'new text', visible: true }, - external: { visible: true }, + external: 'external', }; const newState = messageService.patch(message); expect(newState).toEqual({ - timer: { text: 'new text', visible: true, blackout: false, blink: false }, - external: { - text: '', - visible: true, - }, + timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null }, + external: 'external', }); }); @@ -36,11 +33,8 @@ describe('MessageService', () => { const newState = messageService.patch(initialMessage); expect(newState).toEqual({ - timer: { text: 'initial text', visible: true, blackout: false, blink: false }, - external: { - text: '', - visible: false, - }, + timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null }, + external: '', }); }); }); diff --git a/apps/server/src/services/message-service/__tests__/messageUtils.test.ts b/apps/server/src/services/message-service/__tests__/messageUtils.test.ts index efe40d688..50db0eadc 100644 --- a/apps/server/src/services/message-service/__tests__/messageUtils.test.ts +++ b/apps/server/src/services/message-service/__tests__/messageUtils.test.ts @@ -2,26 +2,7 @@ import { validateMessage, validateTimerMessage } from '../messageUtils.js'; describe('validateMessage()', () => { it('returns a valid Message object', () => { - const payload = { - text: '12312', - visible: 'true', - }; - const expected = { - text: '12312', - visible: true, - }; - - expect(validateMessage(payload)).toEqual(expected); - }); - it('skips keys not given', () => { - const payload = { - visible: 'true', - }; - const expected = { - visible: true, - }; - - expect(validateMessage(payload)).toStrictEqual(expected); + expect(validateMessage('test')).toEqual('test'); }); }); diff --git a/apps/server/src/services/message-service/messageUtils.ts b/apps/server/src/services/message-service/messageUtils.ts index 9c2f12601..e10195c60 100644 --- a/apps/server/src/services/message-service/messageUtils.ts +++ b/apps/server/src/services/message-service/messageUtils.ts @@ -1,4 +1,4 @@ -import { Message, TimerMessage } from 'ontime-types'; +import { TimerMessage } from 'ontime-types'; import * as assert from '../../utils/assert.js'; import { coerceBoolean, coerceString } from '../../utils/coerceType.js'; @@ -7,14 +7,8 @@ import { coerceBoolean, coerceString } from '../../utils/coerceType.js'; * Creates a valid Message object from a payload * @throws if the payload is not an object */ -export function validateMessage(message: unknown): Partial { - assert.isObject(message); - - const result: Partial = {}; - if ('text' in message) result.text = coerceString(message.text); - if ('visible' in message) result.visible = coerceBoolean(message.visible); - - return result; +export function validateMessage(message: unknown): string { + return decodeURI(coerceString(message)); } /** @@ -26,10 +20,28 @@ export function validateTimerMessage(message: unknown): Partial { const result: Partial = {}; - if ('text' in message) result.text = coerceString(message.text); + if ('text' in message) result.text = decodeURI(coerceString(message.text)); if ('visible' in message) result.visible = coerceBoolean(message.visible); if ('blink' in message) result.blink = coerceBoolean(message.blink); if ('blackout' in message) result.blackout = coerceBoolean(message.blackout); + if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource); return result; } + +/** + * Asserts that the secondary value is one of the permitted values + */ +function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] { + return source === 'aux' || source === 'external' || source === null; +} + +/** + * Ensures that the secondary value is one of the permitted values + */ +function coerceSecondary(source: unknown): TimerMessage['secondarySource'] { + if (!assertSecondary(source)) { + return null; + } + return source; +} diff --git a/e2e/tests/features/201-message-control.spec.ts b/e2e/tests/features/201-message-control.spec.ts index b18e63462..98145bd87 100644 --- a/e2e/tests/features/201-message-control.spec.ts +++ b/e2e/tests/features/201-message-control.spec.ts @@ -7,9 +7,9 @@ test('message control sends messages to screens', async ({ context }) => { await editorPage.goto('http://localhost:4001/messagecontrol'); // stage timer message - await editorPage.getByPlaceholder('Timer').click(); - await editorPage.getByPlaceholder('Timer').fill('testing stage'); - await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 }); + await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').click(); + await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').fill('testing stage'); + await editorPage.getByRole('button', { name: /toggle timer message/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/MessageControl.type.ts b/packages/types/src/definitions/runtime/MessageControl.type.ts index 4eeaa5992..74c5d63a9 100644 --- a/packages/types/src/definitions/runtime/MessageControl.type.ts +++ b/packages/types/src/definitions/runtime/MessageControl.type.ts @@ -1,14 +1,12 @@ -export type Message = { +export type TimerMessage = { text: string; visible: boolean; -}; - -export type TimerMessage = Message & { blink: boolean; blackout: boolean; + secondarySource: 'aux' | 'external' | null; }; export type MessageState = { timer: TimerMessage; - external: Message; + external: string; }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3a9aa8b6a..bb2cb0ed3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -58,7 +58,7 @@ export type { RundownCached, NormalisedRundown } from './api/rundown-controller/ export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js'; export { Playback } from './definitions/runtime/Playback.type.js'; export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js'; -export type { Message, TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js'; +export type { TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js'; export type { Runtime } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; @@ -80,4 +80,4 @@ export { isOntimeCycle, isKeyOfType, } from './utils/guards.js'; -export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js'; +export type { MaybeNumber, MaybeString } from './utils/utils.type.js'; diff --git a/packages/types/src/utils/utils.type.ts b/packages/types/src/utils/utils.type.ts index c7841f40b..21b1f2e42 100644 --- a/packages/types/src/utils/utils.type.ts +++ b/packages/types/src/utils/utils.type.ts @@ -1,6 +1,2 @@ export type MaybeNumber = number | null; export type MaybeString = string | null; - -export type DeepPartial = { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; -};