mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 23:19:09 +00:00
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbgWVAXTJUX7E5pAmv6Rs7
This commit is contained in:
@@ -26,6 +26,7 @@ export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
|
|||||||
blackout: state.message.timer.blackout,
|
blackout: state.message.timer.blackout,
|
||||||
blink: state.message.timer.blink,
|
blink: state.message.timer.blink,
|
||||||
secondarySource: state.message.timer.secondarySource,
|
secondarySource: state.message.timer.secondarySource,
|
||||||
|
secondaryPlacement: state.message.timer.secondaryPlacement,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
||||||
@@ -43,6 +44,7 @@ export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
|||||||
blackout: state.message.timer.blackout,
|
blackout: state.message.timer.blackout,
|
||||||
phase: state.timer.phase,
|
phase: state.timer.phase,
|
||||||
secondarySource: state.message.timer.secondarySource,
|
secondarySource: state.message.timer.secondarySource,
|
||||||
|
secondaryPlacement: state.message.timer.secondaryPlacement,
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
timerType: state.eventNow?.timerType ?? null,
|
||||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||||
@@ -56,6 +58,8 @@ export const setMessage = {
|
|||||||
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
|
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
|
||||||
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
|
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
|
||||||
sendSocket('message', { timer: { secondarySource: payload } }),
|
sendSocket('message', { timer: { secondarySource: payload } }),
|
||||||
|
timerSecondaryPlacement: (payload: TimerMessage['secondaryPlacement']) =>
|
||||||
|
sendSocket('message', { timer: { secondaryPlacement: payload } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||||
@@ -227,9 +231,9 @@ export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
|||||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
||||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
countToEndNow: state.eventNow?.countToEnd ?? false,
|
||||||
auxTimer: {
|
auxTimer: {
|
||||||
aux1: state.auxtimer1.current,
|
aux1: { current: state.auxtimer1.current, direction: state.auxtimer1.direction },
|
||||||
aux2: state.auxtimer2.current,
|
aux2: { current: state.auxtimer2.current, direction: state.auxtimer2.direction },
|
||||||
aux3: state.auxtimer3.current,
|
aux3: { current: state.auxtimer3.current, direction: state.auxtimer3.direction },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
|
|
||||||
.secondaryContent {
|
.secondaryContent {
|
||||||
border-top: 1px solid $white-7;
|
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 {
|
.blackout {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
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 { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
|
||||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
@@ -20,10 +20,11 @@ const secondarySourceLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TimerPreview() {
|
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 { data } = useViewSettings();
|
||||||
|
|
||||||
const main = (() => {
|
const eventLabel = (() => {
|
||||||
if (showTimerMessage) return 'Message';
|
if (showTimerMessage) return 'Message';
|
||||||
if (timerType === TimerType.None) return timerPlaceholder;
|
if (timerType === TimerType.None) return timerPlaceholder;
|
||||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||||
@@ -33,7 +34,7 @@ export default function TimerPreview() {
|
|||||||
return 'Timer';
|
return 'Timer';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const secondary = (() => {
|
const secondaryLabel = (() => {
|
||||||
// message is a fullscreen overlay or secondary is not active
|
// message is a fullscreen overlay or secondary is not active
|
||||||
if (showTimerMessage || !secondarySource) return null;
|
if (showTimerMessage || !secondarySource) return null;
|
||||||
|
|
||||||
@@ -41,6 +42,11 @@ export default function TimerPreview() {
|
|||||||
return secondarySourceLabels[secondarySource];
|
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 = (() => {
|
const overrideColour = (() => {
|
||||||
// override fallback colours from starter project
|
// override fallback colours from starter project
|
||||||
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
|
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
|
||||||
@@ -48,7 +54,9 @@ export default function TimerPreview() {
|
|||||||
return data.normalColor ?? '#FFFC';
|
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]);
|
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,12 +65,20 @@ export default function TimerPreview() {
|
|||||||
<div className={contentClasses}>
|
<div className={contentClasses}>
|
||||||
<div
|
<div
|
||||||
className={style.mainContent}
|
className={style.mainContent}
|
||||||
data-phase={showColourOverride && phase}
|
data-phase={eventInMain && showColourOverride && phase}
|
||||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
style={eventInMain && showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||||
>
|
>
|
||||||
{main}
|
{mainDisplay}
|
||||||
</div>
|
</div>
|
||||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
{secondaryDisplay !== null && (
|
||||||
|
<div
|
||||||
|
className={style.secondaryContent}
|
||||||
|
data-phase={!eventInMain && showColourOverride && phase}
|
||||||
|
style={!eventInMain && showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||||
|
>
|
||||||
|
{secondaryDisplay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.eventStatus}>
|
<div className={style.eventStatus}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -105,6 +121,14 @@ export default function TimerPreview() {
|
|||||||
>
|
>
|
||||||
<LuArrowDownToLine />
|
<LuArrowDownToLine />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='Secondary swapped into main slot'
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={isSwapped}
|
||||||
|
>
|
||||||
|
<IoSwapVertical />
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { SecondarySource } from 'ontime-types';
|
import { SecondaryPlacement, SecondarySource } from 'ontime-types';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
import Button from '../../../common/components/buttons/Button';
|
||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
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 Select from '../../../common/components/select/Select';
|
||||||
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
||||||
import TimerPreview from './TimerPreview';
|
import TimerPreview from './TimerPreview';
|
||||||
@@ -42,7 +43,7 @@ export default function TimerControlsPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SecondarySourceControl() {
|
function SecondarySourceControl() {
|
||||||
const { secondarySource } = useTimerViewControl();
|
const { secondarySource, secondaryPlacement } = useTimerViewControl();
|
||||||
const [value, setValue] = useState<SecondarySource>('aux1');
|
const [value, setValue] = useState<SecondarySource>('aux1');
|
||||||
|
|
||||||
// sync secondary source with external changes
|
// sync secondary source with external changes
|
||||||
@@ -52,6 +53,8 @@ function SecondarySourceControl() {
|
|||||||
}
|
}
|
||||||
}, [secondarySource]);
|
}, [secondarySource]);
|
||||||
|
|
||||||
|
const isActive = secondarySource !== null;
|
||||||
|
|
||||||
const toggleSecondary = () => {
|
const toggleSecondary = () => {
|
||||||
if (secondarySource === value) {
|
if (secondarySource === value) {
|
||||||
setMessage.timerSecondarySource(null);
|
setMessage.timerSecondarySource(null);
|
||||||
@@ -79,12 +82,19 @@ function SecondarySourceControl() {
|
|||||||
setValue(value);
|
setValue(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Editor.Label htmlFor='secondary-placement'>Placement</Editor.Label>
|
||||||
variant={secondarySource !== null ? 'primary' : 'subtle'}
|
<RadioGroup<SecondaryPlacement>
|
||||||
fluid
|
id='secondary-placement'
|
||||||
onClick={toggleSecondary}
|
orientation='horizontal'
|
||||||
data-testid='toggle secondary'
|
value={secondaryPlacement}
|
||||||
>
|
disabled={!isActive}
|
||||||
|
onValueChange={(placement) => setMessage.timerSecondaryPlacement(placement)}
|
||||||
|
items={[
|
||||||
|
{ value: 'below', label: 'Below timer' },
|
||||||
|
{ value: 'main', label: 'Swap with timer' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button variant={isActive ? 'primary' : 'subtle'} fluid onClick={toggleSecondary} data-testid='toggle secondary'>
|
||||||
Show secondary
|
Show secondary
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -154,6 +154,28 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
height: 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 {
|
.progress-container {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
getShowMessage,
|
getShowMessage,
|
||||||
getShowModifiers,
|
getShowModifiers,
|
||||||
getShowProgressBar,
|
getShowProgressBar,
|
||||||
|
getTimerSlots,
|
||||||
getTotalTime,
|
getTotalTime,
|
||||||
} from './timer.utils';
|
} from './timer.utils';
|
||||||
import { TimerData, useTimerData } from './useTimerData';
|
import { TimerData, useTimerData } from './useTimerData';
|
||||||
@@ -132,15 +133,25 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
hideSecondary,
|
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
|
// gather presentation styles
|
||||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
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 subduePaused = !isPlaying && viewTimerType !== TimerType.Clock;
|
||||||
const userStyles = {
|
const userStyles = {
|
||||||
...(keyColour && { '--timer-bg': keyColour }),
|
...(keyColour && { '--timer-bg': keyColour }),
|
||||||
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
|
|
||||||
...(font && { '--timer-font': font }),
|
...(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
|
// gather option data
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
@@ -175,17 +186,32 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
</FitText>
|
</FitText>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx([
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
'timer',
|
||||||
data-type={viewTimerType}
|
mainSlot.isEventTimer && subduePaused && 'timer--paused',
|
||||||
data-phase={time.phase}
|
mainSlot.isEventTimer && showFinished && 'timer--finished',
|
||||||
|
])}
|
||||||
|
style={{ fontSize: `${timerFontSize}vw`, ...(mainSlot.isEventTimer ? eventTimerColour : {}) }}
|
||||||
|
data-type={mainSlot.timerType}
|
||||||
|
data-phase={mainSlot.phase}
|
||||||
>
|
>
|
||||||
{display}
|
{mainSlot.content}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
<div
|
||||||
|
className={cx([
|
||||||
|
'secondary',
|
||||||
|
!secondarySlot.content && 'secondary--hidden',
|
||||||
|
secondarySlot.isEventTimer && 'secondary--as-timer',
|
||||||
|
secondarySlot.isEventTimer && subduePaused && 'secondary--paused',
|
||||||
|
secondarySlot.isEventTimer && showFinished && 'secondary--finished',
|
||||||
|
])}
|
||||||
|
style={secondarySlot.isEventTimer ? eventTimerColour : undefined}
|
||||||
|
data-type={secondarySlot.timerType}
|
||||||
|
data-phase={secondarySlot.phase}
|
||||||
|
>
|
||||||
<FitText mode='multi' min={64} max={256}>
|
<FitText mode='multi' min={64} max={256}>
|
||||||
{secondaryContent}
|
{secondarySlot.content}
|
||||||
</FitText>
|
</FitText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { MessageState, SimpleDirection, TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getSecondaryDisplay, getTimerSlots } from './timer.utils';
|
||||||
|
|
||||||
|
function makeMessage(partial: Partial<MessageState['timer']> = {}, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
RundownEntries,
|
RundownEntries,
|
||||||
|
SimpleDirection,
|
||||||
TimerMessage,
|
TimerMessage,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerType,
|
TimerType,
|
||||||
@@ -12,6 +13,11 @@ import { isPlaybackActive } from 'ontime-utils';
|
|||||||
|
|
||||||
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
|
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
|
* Whether a message should be shown
|
||||||
*/
|
*/
|
||||||
@@ -119,7 +125,7 @@ export function getShowModifiers(
|
|||||||
*/
|
*/
|
||||||
export function getSecondaryDisplay(
|
export function getSecondaryDisplay(
|
||||||
message: MessageState,
|
message: MessageState,
|
||||||
currentAux: MaybeNumber,
|
currentAux: AuxTimerValue | null,
|
||||||
localisedMinutes: string,
|
localisedMinutes: string,
|
||||||
removeSeconds: boolean,
|
removeSeconds: boolean,
|
||||||
removeLeadingZero: boolean,
|
removeLeadingZero: boolean,
|
||||||
@@ -133,7 +139,9 @@ export function getSecondaryDisplay(
|
|||||||
message.timer.secondarySource === 'aux2' ||
|
message.timer.secondarySource === 'aux2' ||
|
||||||
message.timer.secondarySource === 'aux3'
|
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,
|
removeSeconds,
|
||||||
removeLeadingZero,
|
removeLeadingZero,
|
||||||
});
|
});
|
||||||
@@ -144,6 +152,45 @@ export function getSecondaryDisplay(
|
|||||||
return;
|
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?
|
* What should we be showing in the cards?
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -33,4 +33,11 @@ describe('validateTimerMessage()', () => {
|
|||||||
|
|
||||||
expect(validateTimerMessage(payload)).toStrictEqual(expected);
|
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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
|
|||||||
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
||||||
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
||||||
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
|
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
|
||||||
|
if ('secondaryPlacement' in message) result.secondaryPlacement = coercePlacement(message.secondaryPlacement);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -45,3 +46,20 @@ function coerceSecondary(source: unknown): TimerMessage['secondarySource'] {
|
|||||||
}
|
}
|
||||||
return source;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
export type SecondarySource = 'aux1' | 'aux2' | 'aux3' | 'secondary' | null;
|
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 = {
|
export type TimerMessage = {
|
||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
blink: boolean;
|
blink: boolean;
|
||||||
blackout: boolean;
|
blackout: boolean;
|
||||||
secondarySource: SecondarySource;
|
secondarySource: SecondarySource;
|
||||||
|
secondaryPlacement: SecondaryPlacement;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageState = {
|
export type MessageState = {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
|||||||
blink: false,
|
blink: false,
|
||||||
blackout: false,
|
blackout: false,
|
||||||
secondarySource: null,
|
secondarySource: null,
|
||||||
|
secondaryPlacement: 'below',
|
||||||
},
|
},
|
||||||
secondary: '',
|
secondary: '',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||||
export { Playback } from './definitions/runtime/Playback.type.js';
|
export { Playback } from './definitions/runtime/Playback.type.js';
|
||||||
export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.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 { RundownState } from './definitions/runtime/RundownState.type.js';
|
||||||
export type { Offset } from './definitions/runtime/Offset.type.js';
|
export type { Offset } from './definitions/runtime/Offset.type.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user