From 077748e5a4e5a9b0f1b6c0e782742e2aa5456b6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:27:32 +0000 Subject: [PATCH] feat(timer): allow swapping the secondary source into the main timer slot The timer view lets operators show an aux timer or the secondary message as a smaller secondary timer under the main event timer. This adds a placement control so that selected source can be promoted into the main slot, swapping positions with the event timer. The swap is deliberate: the event timer is demoted to the secondary slot rather than removed, so the show-critical countdown (and its phase colour) is never lost from screen. - add `SecondaryPlacement` ('below' | 'main') to the timer message, with server validation and a store default - expose the aux timer direction to the timer view and honour it when formatting a promoted aux (previously hard-coded to count-down) - add a `getTimerSlots` helper that assigns the event timer and secondary content to the main/secondary slots, routing phase colour, paused/finished styling and font sizing to whichever slot holds the event timer - add a Placement radio control to the timer view panel (disabled until a secondary source is active) and reflect the swap in the control preview Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MbgWVAXTJUX7E5pAmv6Rs7 --- apps/client/src/common/hooks/useSocket.ts | 10 ++- .../control/message/TimerPreview.module.scss | 9 ++ .../features/control/message/TimerPreview.tsx | 42 +++++++-- .../control/message/TimerViewControl.tsx | 26 ++++-- apps/client/src/views/timer/Timer.scss | 22 +++++ apps/client/src/views/timer/Timer.tsx | 44 +++++++-- .../src/views/timer/timer.utils.test.ts | 89 +++++++++++++++++++ apps/client/src/views/timer/timer.utils.ts | 51 ++++++++++- .../__tests__/message.utils.test.ts | 7 ++ .../services/message-service/message.utils.ts | 18 ++++ .../runtime/MessageControl.type.ts | 8 ++ .../src/definitions/runtime/RuntimeStore.ts | 1 + packages/types/src/index.ts | 7 +- 13 files changed, 302 insertions(+), 32 deletions(-) create mode 100644 apps/client/src/views/timer/timer.utils.test.ts diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index ab14f259e..bb46f0dce 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -26,6 +26,7 @@ export const useTimerViewControl = createSelector((state: RuntimeStore) => ({ blackout: state.message.timer.blackout, blink: state.message.timer.blink, secondarySource: state.message.timer.secondarySource, + secondaryPlacement: state.message.timer.secondaryPlacement, })); export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({ @@ -43,6 +44,7 @@ export const useMessagePreview = createSelector((state: RuntimeStore) => ({ blackout: state.message.timer.blackout, phase: state.timer.phase, secondarySource: state.message.timer.secondarySource, + secondaryPlacement: state.message.timer.secondaryPlacement, showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), timerType: state.eventNow?.timerType ?? null, countToEnd: state.eventNow?.countToEnd ?? false, @@ -56,6 +58,8 @@ export const setMessage = { timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), timerSecondarySource: (payload: TimerMessage['secondarySource']) => sendSocket('message', { timer: { secondarySource: payload } }), + timerSecondaryPlacement: (payload: TimerMessage['secondaryPlacement']) => + sendSocket('message', { timer: { secondaryPlacement: payload } }), }; export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ @@ -227,9 +231,9 @@ export const useTimerSocket = createSelector((state: RuntimeStore) => ({ timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown, countToEndNow: state.eventNow?.countToEnd ?? false, auxTimer: { - aux1: state.auxtimer1.current, - aux2: state.auxtimer2.current, - aux3: state.auxtimer3.current, + aux1: { current: state.auxtimer1.current, direction: state.auxtimer1.direction }, + aux2: { current: state.auxtimer2.current, direction: state.auxtimer2.direction }, + aux3: { current: state.auxtimer3.current, direction: state.auxtimer3.direction }, }, })); diff --git a/apps/client/src/features/control/message/TimerPreview.module.scss b/apps/client/src/features/control/message/TimerPreview.module.scss index 7f64c4856..7fce0f54c 100644 --- a/apps/client/src/features/control/message/TimerPreview.module.scss +++ b/apps/client/src/features/control/message/TimerPreview.module.scss @@ -25,6 +25,15 @@ .secondaryContent { border-top: 1px solid $white-7; + // when the event timer is demoted here (secondary swapped to main) it keeps its colour treatment + color: var(--override-colour, inherit); + + &[data-phase='pending'] { + color: $ontime-roll; + } + &[data-phase='overtime'] { + color: $playback-negative; + } } .blackout { diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx index 62c7e6709..1990dc137 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -1,5 +1,5 @@ import { TimerPhase, TimerType } from 'ontime-types'; -import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5'; +import { IoArrowDown, IoArrowUp, IoBan, IoSwapVertical, IoTime } from 'react-icons/io5'; import { LuArrowDownToLine } from 'react-icons/lu'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; @@ -20,10 +20,11 @@ const secondarySourceLabels: Record = { }; export default function TimerPreview() { - const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview(); + const { blink, blackout, countToEnd, phase, secondarySource, secondaryPlacement, showTimerMessage, timerType } = + useMessagePreview(); const { data } = useViewSettings(); - const main = (() => { + const eventLabel = (() => { if (showTimerMessage) return 'Message'; if (timerType === TimerType.None) return timerPlaceholder; if (phase === TimerPhase.Pending) return 'Standby to start'; @@ -33,7 +34,7 @@ export default function TimerPreview() { return 'Timer'; })(); - const secondary = (() => { + const secondaryLabel = (() => { // message is a fullscreen overlay or secondary is not active if (showTimerMessage || !secondarySource) return null; @@ -41,6 +42,11 @@ export default function TimerPreview() { return secondarySourceLabels[secondarySource]; })(); + // when the secondary is promoted to the main slot the two labels swap; the event timer is demoted + const isSwapped = secondaryPlacement === 'main' && secondaryLabel !== null && !showTimerMessage; + const mainDisplay = isSwapped ? secondaryLabel : eventLabel; + const secondaryDisplay = isSwapped ? eventLabel : secondaryLabel; + const overrideColour = (() => { // override fallback colours from starter project if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528'; @@ -48,7 +54,9 @@ export default function TimerPreview() { return data.normalColor ?? '#FFFC'; })(); - const showColourOverride = main == 'Timer'; + // the event timer keeps its colour treatment in whichever slot it now occupies + const eventInMain = !isSwapped; + const showColourOverride = eventLabel == 'Timer'; const contentClasses = cx([blink && style.blink, blackout && style.blackout]); return ( @@ -57,12 +65,20 @@ export default function TimerPreview() {
- {main} + {mainDisplay}
- {secondary !== null &&
{secondary}
} + {secondaryDisplay !== null && ( +
+ {secondaryDisplay} +
+ )}
+ } + className={style.statusIcon} + data-active={isSwapped} + > + +
); diff --git a/apps/client/src/features/control/message/TimerViewControl.tsx b/apps/client/src/features/control/message/TimerViewControl.tsx index a561d47b4..24501a0ae 100644 --- a/apps/client/src/features/control/message/TimerViewControl.tsx +++ b/apps/client/src/features/control/message/TimerViewControl.tsx @@ -1,8 +1,9 @@ -import { SecondarySource } from 'ontime-types'; +import { SecondaryPlacement, SecondarySource } from 'ontime-types'; import { useEffect, useState } from 'react'; import Button from '../../../common/components/buttons/Button'; import * as Editor from '../../../common/components/editor-utils/EditorUtils'; +import RadioGroup from '../../../common/components/radio-group/RadioGroup'; import Select from '../../../common/components/select/Select'; import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket'; import TimerPreview from './TimerPreview'; @@ -42,7 +43,7 @@ export default function TimerControlsPreview() { } function SecondarySourceControl() { - const { secondarySource } = useTimerViewControl(); + const { secondarySource, secondaryPlacement } = useTimerViewControl(); const [value, setValue] = useState('aux1'); // sync secondary source with external changes @@ -52,6 +53,8 @@ function SecondarySourceControl() { } }, [secondarySource]); + const isActive = secondarySource !== null; + const toggleSecondary = () => { if (secondarySource === value) { setMessage.timerSecondarySource(null); @@ -79,12 +82,19 @@ function SecondarySourceControl() { setValue(value); }} /> - diff --git a/apps/client/src/views/timer/Timer.scss b/apps/client/src/views/timer/Timer.scss index 5f3b9bf98..073ed0f5d 100644 --- a/apps/client/src/views/timer/Timer.scss +++ b/apps/client/src/views/timer/Timer.scss @@ -154,6 +154,28 @@ opacity: 0; height: 0; } + + // when the event timer is demoted into the secondary slot it keeps its (phase-aware) colour + &--as-timer { + color: var(--timer-colour, var(--timer-color-override, $ui-white)); + border-top-color: color-mix(in srgb, var(--timer-colour, $external-color) 10%, transparent); + + &.secondary--paused { + opacity: $viewer-opacity-disabled; + transition: $viewer-transition-time; + } + + &.secondary--finished { + color: var(--timer-overtime-color-override, $timer-finished-color); + } + + &[data-phase='warning'] { + color: var(--timer-colour, var(--timer-warning-color-override)); + } + &[data-phase='danger'] { + color: var(--timer-colour, var(--timer-danger-color-override)); + } + } } .progress-container { diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index 3ae139d26..6b76f7663 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -27,6 +27,7 @@ import { getShowMessage, getShowModifiers, getShowProgressBar, + getTimerSlots, getTotalTime, } from './timer.utils'; import { TimerData, useTimerData } from './useTimerData'; @@ -132,15 +133,25 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, hideSecondary, ); + // when the operator promotes the secondary source to the main slot, swap the two so the event + // timer is demoted (never removed). Frozen overtime end-messages keep the event timer prominent. + const isSwapped = message.timer.secondaryPlacement === 'main' && Boolean(secondaryContent) && !showEndMessage; + const { main: mainSlot, secondary: secondarySlot } = getTimerSlots( + isSwapped, + { content: display, timerType: viewTimerType, phase: time.phase }, + secondaryContent, + ); + // gather presentation styles const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger); - const timerFontSize = getEstimatedFontSize(display, secondaryContent); + const timerFontSize = getEstimatedFontSize(mainSlot.content ?? display, secondarySlot.content); const subduePaused = !isPlaying && viewTimerType !== TimerType.Clock; const userStyles = { ...(keyColour && { '--timer-bg': keyColour }), - ...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }), ...(font && { '--timer-font': font }), }; + // the event timer keeps its (phase-aware) colour in whichever slot it occupies + const eventTimerColour = resolvedTimerColour ? { '--timer-colour': resolvedTimerColour } : undefined; // gather option data const defaultFormat = getDefaultFormat(settings?.timeFormat); @@ -175,17 +186,32 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, ) : (
- {display} + {mainSlot.content}
)} -
+
- {secondaryContent} + {secondarySlot.content}
diff --git a/apps/client/src/views/timer/timer.utils.test.ts b/apps/client/src/views/timer/timer.utils.test.ts new file mode 100644 index 000000000..1c519f010 --- /dev/null +++ b/apps/client/src/views/timer/timer.utils.test.ts @@ -0,0 +1,89 @@ +import { MessageState, SimpleDirection, TimerPhase, TimerType } from 'ontime-types'; + +import { getSecondaryDisplay, getTimerSlots } from './timer.utils'; + +function makeMessage(partial: Partial = {}, secondary = ''): MessageState { + return { + timer: { + text: '', + visible: false, + blink: false, + blackout: false, + secondarySource: null, + secondaryPlacement: 'below', + ...partial, + }, + secondary, + }; +} + +const eventTimer = { content: '00:10:00', timerType: TimerType.CountDown, phase: TimerPhase.Warning }; + +describe('getTimerSlots()', () => { + it('keeps the event timer in the main slot when not swapped', () => { + const { main, secondary } = getTimerSlots(false, eventTimer, 'AUX'); + + expect(main).toMatchObject({ content: '00:10:00', phase: TimerPhase.Warning, isEventTimer: true }); + expect(secondary).toMatchObject({ content: 'AUX', phase: undefined, isEventTimer: false }); + }); + + it('swaps the secondary into the main slot and demotes the event timer', () => { + const { main, secondary } = getTimerSlots(true, eventTimer, 'AUX'); + + expect(main).toMatchObject({ content: 'AUX', isEventTimer: false, phase: undefined }); + // the event timer is never removed, only demoted, and keeps its phase + expect(secondary).toMatchObject({ content: '00:10:00', isEventTimer: true, phase: TimerPhase.Warning }); + }); + + it('does not swap when there is no secondary content to promote', () => { + const { main, secondary } = getTimerSlots(true, eventTimer, undefined); + + expect(main.isEventTimer).toBe(true); + expect(secondary.isEventTimer).toBe(false); + }); +}); + +describe('getSecondaryDisplay()', () => { + it('returns nothing when the secondary is hidden', () => { + const message = makeMessage({ secondarySource: 'aux1' }); + expect( + getSecondaryDisplay(message, { current: 5000, direction: SimpleDirection.CountDown }, 'min', false, false, true), + ).toBeUndefined(); + }); + + it('returns the secondary message text for the secondary source', () => { + const message = makeMessage({ secondarySource: 'secondary' }, 'hello'); + expect(getSecondaryDisplay(message, null, 'min', false, false, false)).toBe('hello'); + }); + + it('formats an aux source as a timer honouring its direction', () => { + const message = makeMessage({ secondarySource: 'aux1' }); + + // a running count-up aux shows elapsed time without a negative sign + const countUp = getSecondaryDisplay( + message, + { current: 5000, direction: SimpleDirection.CountUp }, + 'min', + false, + false, + false, + ); + expect(countUp).toBe('00:00:05'); + + // a count-down aux past zero shows overtime as a negative value + const countDown = getSecondaryDisplay( + message, + { current: -5000, direction: SimpleDirection.CountDown }, + 'min', + false, + false, + false, + ); + expect(countDown).toBe('-00:00:05'); + }); + + it('returns nothing when no secondary source is selected', () => { + const message = makeMessage({ secondarySource: null }); + expect(getSecondaryDisplay(message, null, 'min', false, false, false)).toBeUndefined(); + }); +}); diff --git a/apps/client/src/views/timer/timer.utils.ts b/apps/client/src/views/timer/timer.utils.ts index 1add32d88..161bef9dd 100644 --- a/apps/client/src/views/timer/timer.utils.ts +++ b/apps/client/src/views/timer/timer.utils.ts @@ -4,6 +4,7 @@ import { OntimeEvent, Playback, RundownEntries, + SimpleDirection, TimerMessage, TimerPhase, TimerType, @@ -12,6 +13,11 @@ import { isPlaybackActive } from 'ontime-utils'; import { getFormattedTimer, getPropertyValue } from '../common/viewUtils'; +/** + * The current value and direction of the aux timer feeding the secondary slot + */ +export type AuxTimerValue = { current: MaybeNumber; direction: SimpleDirection }; + /** * Whether a message should be shown */ @@ -119,7 +125,7 @@ export function getShowModifiers( */ export function getSecondaryDisplay( message: MessageState, - currentAux: MaybeNumber, + currentAux: AuxTimerValue | null, localisedMinutes: string, removeSeconds: boolean, removeLeadingZero: boolean, @@ -133,7 +139,9 @@ export function getSecondaryDisplay( message.timer.secondarySource === 'aux2' || message.timer.secondarySource === 'aux3' ) { - return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, { + // honour the aux timer's own direction so a promoted aux reads correctly + const timerType = currentAux?.direction === SimpleDirection.CountUp ? TimerType.CountUp : TimerType.CountDown; + return getFormattedTimer(currentAux?.current ?? null, timerType, localisedMinutes, { removeSeconds, removeLeadingZero, }); @@ -144,6 +152,45 @@ export function getSecondaryDisplay( return; } +/** + * Describes what a timer slot (main or secondary) renders and how it should be styled + */ +export type TimerSlot = { + content: string | undefined; + timerType: TimerType | undefined; + phase: TimerPhase | undefined; + isEventTimer: boolean; +}; + +/** + * Assigns the event timer and the secondary content to the main (large) and secondary (small) slots. + * When the operator promotes the secondary source to the main slot, the two are swapped so the event + * timer is never removed from screen — it is only demoted to the smaller slot. + */ +export function getTimerSlots( + isSwapped: boolean, + eventTimer: { content: string; timerType: TimerType; phase: TimerPhase }, + secondaryContent: string | undefined, +): { main: TimerSlot; secondary: TimerSlot } { + const eventSlot: TimerSlot = { + content: eventTimer.content, + timerType: eventTimer.timerType, + phase: eventTimer.phase, + isEventTimer: true, + }; + const secondarySlot: TimerSlot = { + content: secondaryContent, + timerType: undefined, + phase: undefined, + isEventTimer: false, + }; + + if (isSwapped && secondaryContent) { + return { main: secondarySlot, secondary: eventSlot }; + } + return { main: eventSlot, secondary: secondarySlot }; +} + /** * What should we be showing in the cards? */ diff --git a/apps/server/src/services/message-service/__tests__/message.utils.test.ts b/apps/server/src/services/message-service/__tests__/message.utils.test.ts index a4354bde1..21c6a0c12 100644 --- a/apps/server/src/services/message-service/__tests__/message.utils.test.ts +++ b/apps/server/src/services/message-service/__tests__/message.utils.test.ts @@ -33,4 +33,11 @@ describe('validateTimerMessage()', () => { expect(validateTimerMessage(payload)).toStrictEqual(expected); }); + it('coerces the secondary placement to a permitted value', () => { + expect(validateTimerMessage({ secondaryPlacement: 'main' })).toStrictEqual({ secondaryPlacement: 'main' }); + expect(validateTimerMessage({ secondaryPlacement: 'below' })).toStrictEqual({ secondaryPlacement: 'below' }); + }); + it('falls back to below for an invalid placement', () => { + expect(validateTimerMessage({ secondaryPlacement: 'nonsense' })).toStrictEqual({ secondaryPlacement: 'below' }); + }); }); diff --git a/apps/server/src/services/message-service/message.utils.ts b/apps/server/src/services/message-service/message.utils.ts index 5e96357f5..0e1015945 100644 --- a/apps/server/src/services/message-service/message.utils.ts +++ b/apps/server/src/services/message-service/message.utils.ts @@ -25,6 +25,7 @@ export function validateTimerMessage(message: unknown): Partial { 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); + if ('secondaryPlacement' in message) result.secondaryPlacement = coercePlacement(message.secondaryPlacement); return result; } @@ -45,3 +46,20 @@ function coerceSecondary(source: unknown): TimerMessage['secondarySource'] { } return source; } + +/** + * Asserts that the placement value is one of the permitted values + */ +function assertPlacement(placement: unknown): placement is TimerMessage['secondaryPlacement'] { + return placement === 'below' || placement === 'main'; +} + +/** + * Ensures that the placement value is one of the permitted values + */ +function coercePlacement(placement: unknown): TimerMessage['secondaryPlacement'] { + if (!assertPlacement(placement)) { + return 'below'; + } + return placement; +} diff --git a/packages/types/src/definitions/runtime/MessageControl.type.ts b/packages/types/src/definitions/runtime/MessageControl.type.ts index bf448bb73..14761b152 100644 --- a/packages/types/src/definitions/runtime/MessageControl.type.ts +++ b/packages/types/src/definitions/runtime/MessageControl.type.ts @@ -1,11 +1,19 @@ export type SecondarySource = 'aux1' | 'aux2' | 'aux3' | 'secondary' | null; +/** + * Where the selected secondary source is displayed in the timer view + * - below: shown as a smaller timer under the main timer (default) + * - main: swapped into the main slot, demoting the event timer to the secondary slot + */ +export type SecondaryPlacement = 'below' | 'main'; + export type TimerMessage = { text: string; visible: boolean; blink: boolean; blackout: boolean; secondarySource: SecondarySource; + secondaryPlacement: SecondaryPlacement; }; export type MessageState = { diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index a5e899abd..eeafca96c 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -24,6 +24,7 @@ export const runtimeStorePlaceholder: Readonly = { blink: false, blackout: false, secondarySource: null, + secondaryPlacement: 'below', }, secondary: '', }, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d56bbde2d..7b36d1b3a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -108,7 +108,12 @@ export type { ApiAction, ApiActionTag, ApiResponse } from './api/websocket/api.t export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js'; export { Playback } from './definitions/runtime/Playback.type.js'; export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js'; -export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js'; +export type { + TimerMessage, + MessageState, + SecondarySource, + SecondaryPlacement, +} from './definitions/runtime/MessageControl.type.js'; export type { RundownState } from './definitions/runtime/RundownState.type.js'; export type { Offset } from './definitions/runtime/Offset.type.js';