Compare commits

..

2 Commits

Author SHA1 Message Date
Claude 7a55aab400 test(timer): assert elapsed stays frozen while paused over midnight
Make the midnight pause test's intent explicit: elapsed is active time
since start and must not advance during a pause (even one crossing
midnight). Add a frozen-elapsed assertion while paused and keep
pausedDuration - the corrupted pause count - as the headline assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-07-14 10:47:40 +00:00
Claude 7b4ebbf7f5 test(timer): expose pause-over-midnight duration bug
Pause is tracked as pausedAt (TimeOfDay, ms since local midnight) and
paused duration is derived via the naive `clock - pausedAt`. When a pause
spans midnight the clock has wrapped to a small value while pausedAt is
still large, so the subtraction goes negative and every paused-duration
result is corrupted (runtimeState.start resume accumulation, and
getExpectedFinish/getCurrent/getRuntimeOffset in timerUtils).

Add two currently-failing tests that reproduce this:
- runtimeState: full start/pause/resume cycle where the pause crosses
  midnight, asserting pausedDuration and elapsed exclude the pause.
- timerUtils.getRuntimeOffset: over-midnight variant of the paused-offset
  case (the site carrying the "brakes when crossing midnight" TODO).

Both fail today (report ~ -86,100,000 instead of the real 5-minute pause)
and will pass once the pause math adopts the wrap-aware primitives
(timeCore.elapsedTime / epoch-based tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-07-14 10:41:50 +00:00
15 changed files with 115 additions and 302 deletions
+3 -7
View File
@@ -26,7 +26,6 @@ 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) => ({
@@ -44,7 +43,6 @@ 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,
@@ -58,8 +56,6 @@ 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) => ({
@@ -231,9 +227,9 @@ export const useTimerSocket = createSelector((state: RuntimeStore) => ({
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
countToEndNow: state.eventNow?.countToEnd ?? false,
auxTimer: {
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 },
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
}));
@@ -25,15 +25,6 @@
.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 {
@@ -1,5 +1,5 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoSwapVertical, IoTime } from 'react-icons/io5';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
@@ -20,11 +20,10 @@ const secondarySourceLabels: Record<string, string> = {
};
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, secondaryPlacement, showTimerMessage, timerType } =
useMessagePreview();
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const eventLabel = (() => {
const main = (() => {
if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start';
@@ -34,7 +33,7 @@ export default function TimerPreview() {
return 'Timer';
})();
const secondaryLabel = (() => {
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null;
@@ -42,11 +41,6 @@ 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';
@@ -54,9 +48,7 @@ export default function TimerPreview() {
return data.normalColor ?? '#FFFC';
})();
// the event timer keeps its colour treatment in whichever slot it now occupies
const eventInMain = !isSwapped;
const showColourOverride = eventLabel == 'Timer';
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return (
@@ -65,20 +57,12 @@ export default function TimerPreview() {
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={eventInMain && showColourOverride && phase}
style={eventInMain && showColourOverride ? { '--override-colour': overrideColour } : {}}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{mainDisplay}
{main}
</div>
{secondaryDisplay !== null && (
<div
className={style.secondaryContent}
data-phase={!eventInMain && showColourOverride && phase}
style={!eventInMain && showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{secondaryDisplay}
</div>
)}
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
@@ -121,14 +105,6 @@ export default function TimerPreview() {
>
<LuArrowDownToLine />
</Tooltip>
<Tooltip
text='Secondary swapped into main slot'
render={<span />}
className={style.statusIcon}
data-active={isSwapped}
>
<IoSwapVertical />
</Tooltip>
</div>
</div>
);
@@ -1,9 +1,8 @@
import { SecondaryPlacement, SecondarySource } from 'ontime-types';
import { 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';
@@ -43,7 +42,7 @@ export default function TimerControlsPreview() {
}
function SecondarySourceControl() {
const { secondarySource, secondaryPlacement } = useTimerViewControl();
const { secondarySource } = useTimerViewControl();
const [value, setValue] = useState<SecondarySource>('aux1');
// sync secondary source with external changes
@@ -53,8 +52,6 @@ function SecondarySourceControl() {
}
}, [secondarySource]);
const isActive = secondarySource !== null;
const toggleSecondary = () => {
if (secondarySource === value) {
setMessage.timerSecondarySource(null);
@@ -82,19 +79,12 @@ function SecondarySourceControl() {
setValue(value);
}}
/>
<Editor.Label htmlFor='secondary-placement'>Placement</Editor.Label>
<RadioGroup<SecondaryPlacement>
id='secondary-placement'
orientation='horizontal'
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'>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
-22
View File
@@ -154,28 +154,6 @@
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 {
+9 -35
View File
@@ -27,7 +27,6 @@ import {
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTimerSlots,
getTotalTime,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
@@ -133,25 +132,15 @@ 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(mainSlot.content ?? display, secondarySlot.content);
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
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);
@@ -186,32 +175,17 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
</FitText>
) : (
<div
className={cx([
'timer',
mainSlot.isEventTimer && subduePaused && 'timer--paused',
mainSlot.isEventTimer && showFinished && 'timer--finished',
])}
style={{ fontSize: `${timerFontSize}vw`, ...(mainSlot.isEventTimer ? eventTimerColour : {}) }}
data-type={mainSlot.timerType}
data-phase={mainSlot.phase}
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={time.phase}
>
{mainSlot.content}
{display}
</div>
)}
<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}
>
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={64} max={256}>
{secondarySlot.content}
{secondaryContent}
</FitText>
</div>
</div>
@@ -1,89 +0,0 @@
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();
});
});
+2 -49
View File
@@ -4,7 +4,6 @@ import {
OntimeEvent,
Playback,
RundownEntries,
SimpleDirection,
TimerMessage,
TimerPhase,
TimerType,
@@ -13,11 +12,6 @@ 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
*/
@@ -125,7 +119,7 @@ export function getShowModifiers(
*/
export function getSecondaryDisplay(
message: MessageState,
currentAux: AuxTimerValue | null,
currentAux: MaybeNumber,
localisedMinutes: string,
removeSeconds: boolean,
removeLeadingZero: boolean,
@@ -139,9 +133,7 @@ export function getSecondaryDisplay(
message.timer.secondarySource === 'aux2' ||
message.timer.secondarySource === 'aux3'
) {
// 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, {
return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, {
removeSeconds,
removeLeadingZero,
});
@@ -152,45 +144,6 @@ 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?
*/
@@ -975,6 +975,36 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25);
});
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: 23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE, // 23:58, before midnight
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> 5 minutes, regardless of the midnight wrap
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(5 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => {
const state = {
clock: 78480789,
@@ -33,11 +33,4 @@ 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' });
});
});
@@ -25,7 +25,6 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
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;
}
@@ -46,20 +45,3 @@ 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;
}
@@ -248,6 +248,59 @@ describe('mutation on runtimeState', () => {
state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
});
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
});
test('runtime offset', async () => {
@@ -1,19 +1,11 @@
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 = {
@@ -24,7 +24,6 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
blink: false,
blackout: false,
secondarySource: null,
secondaryPlacement: 'below',
},
secondary: '',
},
+1 -6
View File
@@ -108,12 +108,7 @@ 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,
SecondaryPlacement,
} from './definitions/runtime/MessageControl.type.js';
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
export type { RundownState } from './definitions/runtime/RundownState.type.js';
export type { Offset } from './definitions/runtime/Offset.type.js';